diff --git a/Backend/GestionIntegral.Api/Controllers/Comunicaciones/LotesEnvioController.cs b/Backend/GestionIntegral.Api/Controllers/Comunicaciones/LotesEnvioController.cs new file mode 100644 index 0000000..36ed4b6 --- /dev/null +++ b/Backend/GestionIntegral.Api/Controllers/Comunicaciones/LotesEnvioController.cs @@ -0,0 +1,40 @@ +using GestionIntegral.Api.Dtos.Comunicaciones; +using GestionIntegral.Api.Services.Comunicaciones; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace GestionIntegral.Api.Controllers.Comunicaciones +{ + [Route("api/lotes-envio")] + [ApiController] + [Authorize] + public class LotesEnvioController : ControllerBase + { + private readonly IEmailLogService _emailLogService; + + public LotesEnvioController(IEmailLogService emailLogService) + { + _emailLogService = emailLogService; + } + + // GET: api/lotes-envio/123/detalles + [HttpGet("{idLote:int}/detalles")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetDetallesLote(int idLote) + { + // Reutilizamos un permiso existente, ya que esta es una función de auditoría relacionada. + var tienePermiso = User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == "SU006"); + if (!tienePermiso) + { + return Forbid(); + } + + var detalles = await _emailLogService.ObtenerDetallesPorLoteId(idLote); + + // Devolvemos OK con un array vacío si no hay resultados, el frontend lo manejará. + return Ok(detalles); + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/DistribucionSuscripcionesDocument.cs b/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/DistribucionSuscripcionesDocument.cs index 7753632..5bf63f3 100644 --- a/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/DistribucionSuscripcionesDocument.cs +++ b/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/DistribucionSuscripcionesDocument.cs @@ -49,63 +49,103 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates { container.PaddingTop(10).Column(column => { - column.Spacing(20); - foreach (var empresa in Model.DatosAgrupados) + column.Spacing(20); // Espacio entre elementos principales (sección de altas y sección de bajas) + + // --- Sección 1: Altas y Activas --- + column.Item().Column(colAltas => { - column.Item().Element(c => ComposeEmpresa(c, empresa)); + colAltas.Item().Text("Altas y Suscripciones Activas en el Período").Bold().FontSize(14).Underline(); + colAltas.Item().PaddingBottom(10).Text("Listado de suscriptores que deben recibir entregas en el período seleccionado."); + + if (!Model.DatosAgrupadosAltas.Any()) + { + colAltas.Item().PaddingTop(10).Text("No se encontraron suscripciones activas para este período.").Italic(); + } + else + { + foreach (var empresa in Model.DatosAgrupadosAltas) + { + colAltas.Item().Element(c => ComposeTablaEmpresa(c, empresa, esBaja: false)); + } + } + }); + + // --- Sección 2: Bajas --- + if (Model.DatosAgrupadosBajas.Any()) + { + column.Item().PageBreak(); // Salto de página para separar las secciones + column.Item().Column(colBajas => + { + colBajas.Item().Text("Bajas de Suscripciones en el Período").Bold().FontSize(14).Underline().FontColor(Colors.Red.Medium); + colBajas.Item().PaddingBottom(10).Text("Listado de suscriptores cuya suscripción finalizó. NO se les debe entregar a partir de su 'Fecha de Baja'."); + + foreach (var empresa in Model.DatosAgrupadosBajas) + { + colBajas.Item().Element(c => ComposeTablaEmpresa(c, empresa, esBaja: true)); + } + }); } }); } - void ComposeEmpresa(IContainer container, GrupoEmpresa empresa) + void ComposeTablaEmpresa(IContainer container, GrupoEmpresa empresa, bool esBaja) { container.Column(column => { + // Cabecera de la EMPRESA (ej. EL DIA) column.Item().Background(Colors.Grey.Lighten2).Padding(5).Text(empresa.NombreEmpresa).Bold().FontSize(12); - column.Item().Column(colPub => + + // Contenedor para las tablas de las publicaciones de esta empresa + column.Item().PaddingTop(5).Column(colPub => { - colPub.Spacing(10); + colPub.Spacing(10); // Espacio entre cada tabla de publicación foreach (var publicacion in empresa.Publicaciones) { - colPub.Item().Element(c => ComposePublicacion(c, publicacion)); + colPub.Item().Element(c => ComposeTablaPublicacion(c, publicacion, esBaja)); } }); }); } - void ComposePublicacion(IContainer container, GrupoPublicacion publicacion) + void ComposeTablaPublicacion(IContainer container, GrupoPublicacion publicacion, bool esBaja) { - container.Table(table => + // Se envuelve la tabla en una columna para poder ponerle un título simple arriba. + container.Column(column => { - table.ColumnsDefinition(columns => + column.Item().PaddingLeft(2).PaddingBottom(2).Text(publicacion.NombrePublicacion).SemiBold().FontSize(10); + column.Item().Table(table => { - columns.RelativeColumn(2.5f); // Nombre - columns.RelativeColumn(3); // Dirección - columns.RelativeColumn(1.5f); // Teléfono - columns.RelativeColumn(1.5f); // Días - columns.RelativeColumn(2.5f); // Observaciones + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(2.5f); // Nombre + columns.RelativeColumn(3); // Dirección + columns.RelativeColumn(1.5f); // Teléfono + columns.ConstantColumn(65); // Fecha Inicio / Baja + columns.RelativeColumn(1.5f); // Días + columns.RelativeColumn(2.5f); // Observaciones + }); + + table.Header(header => + { + header.Cell().BorderBottom(1).Padding(2).Text("Suscriptor").SemiBold(); + header.Cell().BorderBottom(1).Padding(2).Text("Dirección").SemiBold(); + header.Cell().BorderBottom(1).Padding(2).Text("Teléfono").SemiBold(); + header.Cell().BorderBottom(1).Padding(2).Text(esBaja ? "Fecha de Baja" : "Fecha Inicio").SemiBold(); + header.Cell().BorderBottom(1).Padding(2).Text("Días Entrega").SemiBold(); + header.Cell().BorderBottom(1).Padding(2).Text("Observaciones").SemiBold(); + }); + + foreach (var item in publicacion.Suscripciones) + { + table.Cell().Padding(2).Text(item.NombreSuscriptor); + table.Cell().Padding(2).Text(item.Direccion); + table.Cell().Padding(2).Text(item.Telefono ?? "-"); + var fecha = esBaja ? item.FechaFin : item.FechaInicio; + table.Cell().Padding(2).Text(fecha?.ToString("dd/MM/yyyy") ?? "-"); + table.Cell().Padding(2).Text(item.DiasEntrega); + table.Cell().Padding(2).Text(item.Observaciones ?? "-"); + } }); - - table.Header(header => - { - header.Cell().ColumnSpan(5).Background(Colors.Grey.Lighten4).Padding(3) - .Text(publicacion.NombrePublicacion).SemiBold().FontSize(10); - - header.Cell().BorderBottom(1).Padding(2).Text("Suscriptor").SemiBold(); - header.Cell().BorderBottom(1).Padding(2).Text("Dirección").SemiBold(); - header.Cell().BorderBottom(1).Padding(2).Text("Teléfono").SemiBold(); - header.Cell().BorderBottom(1).Padding(2).Text("Días Entrega").SemiBold(); - header.Cell().BorderBottom(1).Padding(2).Text("Observaciones").SemiBold(); - }); - - foreach (var item in publicacion.Suscripciones) - { - table.Cell().Padding(2).Text(item.NombreSuscriptor); - table.Cell().Padding(2).Text(item.Direccion); - table.Cell().Padding(2).Text(item.Telefono ?? "-"); - table.Cell().Padding(2).Text(item.DiasEntrega); - table.Cell().Padding(2).Text(item.Observaciones ?? "-"); - } }); } } diff --git a/Backend/GestionIntegral.Api/Controllers/Reportes/ReportesController.cs b/Backend/GestionIntegral.Api/Controllers/Reportes/ReportesController.cs index 2616cbd..b46f0a6 100644 --- a/Backend/GestionIntegral.Api/Controllers/Reportes/ReportesController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Reportes/ReportesController.cs @@ -1727,16 +1727,16 @@ namespace GestionIntegral.Api.Controllers { if (!TienePermiso(PermisoVerReporteDistSuscripciones)) return Forbid(); - var (data, error) = await _reportesService.ObtenerReporteDistribucionSuscripcionesAsync(fechaDesde, fechaHasta); + var (altas, bajas, error) = await _reportesService.ObtenerReporteDistribucionSuscripcionesAsync(fechaDesde, fechaHasta); if (error != null) return BadRequest(new { message = error }); - if (data == null || !data.Any()) + if ((altas == null || !altas.Any()) && (bajas == null || !bajas.Any())) { - return NotFound(new { message = "No se encontraron suscripciones activas para el período seleccionado." }); + return NotFound(new { message = "No se encontraron suscripciones activas ni bajas para el período seleccionado." }); } try { - var viewModel = new DistribucionSuscripcionesViewModel(data) + var viewModel = new DistribucionSuscripcionesViewModel(altas ?? Enumerable.Empty(), bajas ?? Enumerable.Empty()) { FechaDesde = fechaDesde.ToString("dd/MM/yyyy"), FechaHasta = fechaHasta.ToString("dd/MM/yyyy"), diff --git a/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs b/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs index 6505c35..a8315c2 100644 --- a/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs @@ -1,5 +1,4 @@ using GestionIntegral.Api.Dtos.Comunicaciones; -using GestionIntegral.Api.Dtos.Suscripciones; using GestionIntegral.Api.Services.Comunicaciones; using GestionIntegral.Api.Services.Suscripciones; using Microsoft.AspNetCore.Authorization; @@ -54,11 +53,10 @@ namespace GestionIntegral.Api.Controllers.Suscripciones return NoContent(); } - // POST: api/facturacion/{idFactura}/enviar-factura-pdf [HttpPost("{idFactura:int}/enviar-factura-pdf")] public async Task EnviarFacturaPdf(int idFactura) { - if (!TienePermiso("SU009")) return Forbid(); + if (!TienePermiso(PermisoEnviarEmail)) return Forbid(); var userId = GetCurrentUserId(); if (userId == null) return Unauthorized(); var (exito, error, emailDestino) = await _facturacionService.EnviarFacturaPdfPorEmail(idFactura, userId.Value); @@ -72,31 +70,6 @@ namespace GestionIntegral.Api.Controllers.Suscripciones return Ok(new { message = mensajeExito }); } - // POST: api/facturacion/{anio}/{mes}/suscriptor/{idSuscriptor}/enviar-aviso - [HttpPost("{anio:int}/{mes:int}/suscriptor/{idSuscriptor:int}/enviar-aviso")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task EnviarAvisoPorEmail(int anio, int mes, int idSuscriptor) - { - // Usamos el permiso de enviar email - if (!TienePermiso("SU009")) return Forbid(); - - var (exito, error) = await _facturacionService.EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor); - - if (!exito) - { - if (error != null && (error.Contains("no encontrada") || error.Contains("no es válido"))) - { - return NotFound(new { message = error }); - } - return BadRequest(new { message = error }); - } - - return Ok(new { message = "Email consolidado para el suscriptor ha sido enviado a la cola de procesamiento." }); - } - - // GET: api/facturacion/{anio}/{mes} [HttpGet("{anio:int}/{mes:int}")] public async Task GetFacturas( int anio, int mes, @@ -111,7 +84,6 @@ namespace GestionIntegral.Api.Controllers.Suscripciones return Ok(resumenes); } - [HttpPost("{anio:int}/{mes:int}")] public async Task GenerarFacturacion(int anio, int mes) { @@ -119,16 +91,34 @@ namespace GestionIntegral.Api.Controllers.Suscripciones var userId = GetCurrentUserId(); if (userId == null) return Unauthorized(); if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El año y el mes proporcionados no son válidos." }); - var (exito, mensaje, facturasGeneradas) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value); + + var (exito, mensaje, resultadoEnvio) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value); + if (!exito) return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje }); - return Ok(new { message = mensaje, facturasGeneradas }); + + return Ok(new { message = mensaje, resultadoEnvio }); } + [HttpGet("historial-lotes-envio")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetHistorialLotesEnvio([FromQuery] int? anio, [FromQuery] int? mes) + { + if (!TienePermiso("SU006")) return Forbid(); + var historial = await _facturacionService.ObtenerHistorialLotesEnvio(anio, mes); + return Ok(historial); + } + + // Endpoint para el historial de envíos de una factura individual [HttpGet("{idFactura:int}/historial-envios")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] public async Task GetHistorialEnvios(int idFactura) { - var historial = await _emailLogService.ObtenerHistorialPorReferencia("Factura-" + idFactura); + if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); // Reutilizamos el permiso + + // Construimos la referencia que se guarda en el log + string referencia = $"Factura-{idFactura}"; + var historial = await _emailLogService.ObtenerHistorialPorReferencia(referencia); + return Ok(historial); } } diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/EmailLogRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/EmailLogRepository.cs index 428be53..3432022 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/EmailLogRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/EmailLogRepository.cs @@ -16,10 +16,10 @@ namespace GestionIntegral.Api.Data.Repositories.Comunicaciones public async Task CreateAsync(EmailLog log) { const string sql = @" - INSERT INTO dbo.com_EmailLogs - (FechaEnvio, DestinatarioEmail, Asunto, Estado, Error, IdUsuarioDisparo, Origen, ReferenciaId) - VALUES - (@FechaEnvio, @DestinatarioEmail, @Asunto, @Estado, @Error, @IdUsuarioDisparo, @Origen, @ReferenciaId);"; + INSERT INTO dbo.com_EmailLogs + (FechaEnvio, DestinatarioEmail, Asunto, Estado, Error, IdUsuarioDisparo, Origen, ReferenciaId, IdLoteDeEnvio) + VALUES + (@FechaEnvio, @DestinatarioEmail, @Asunto, @Estado, @Error, @IdUsuarioDisparo, @Origen, @ReferenciaId, @IdLoteDeEnvio);"; using var connection = _connectionFactory.CreateConnection(); await connection.ExecuteAsync(sql, log); @@ -42,5 +42,24 @@ namespace GestionIntegral.Api.Data.Repositories.Comunicaciones return Enumerable.Empty(); } } + + public async Task> GetByLoteIdAsync(int idLoteDeEnvio) + { + // Ordenamos por Estado descendente para que los 'Fallidos' aparezcan primero + const string sql = @" + SELECT * FROM dbo.com_EmailLogs + WHERE IdLoteDeEnvio = @IdLoteDeEnvio + ORDER BY Estado DESC, FechaEnvio DESC;"; + try + { + using var connection = _connectionFactory.CreateConnection(); + return await connection.QueryAsync(sql, new { IdLoteDeEnvio = idLoteDeEnvio }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al obtener logs de email por IdLoteDeEnvio: {IdLoteDeEnvio}", idLoteDeEnvio); + return Enumerable.Empty(); + } + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/IEmailLogRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/IEmailLogRepository.cs index e137959..110fb1e 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/IEmailLogRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/IEmailLogRepository.cs @@ -15,5 +15,12 @@ namespace GestionIntegral.Api.Data.Repositories.Comunicaciones /// El identificador de la entidad (ej. "Factura-59"). /// Una colección de registros de log de email. Task> GetByReferenceAsync(string referenciaId); + + /// + /// Obtiene todos los registros de log de email que pertenecen a un lote de envío masivo. + /// + /// El ID del lote de envío. + /// Una colección de registros de log de email. + Task> GetByLoteIdAsync(int idLoteDeEnvio); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/ILoteDeEnvioRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/ILoteDeEnvioRepository.cs new file mode 100644 index 0000000..2a796f8 --- /dev/null +++ b/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/ILoteDeEnvioRepository.cs @@ -0,0 +1,12 @@ +using GestionIntegral.Api.Models.Comunicaciones; + +namespace GestionIntegral.Api.Data.Repositories.Comunicaciones +{ + public interface ILoteDeEnvioRepository + { + Task CreateAsync(LoteDeEnvio lote); + Task UpdateAsync(LoteDeEnvio lote); + Task> GetAllAsync(int? anio, int? mes); + Task GetByIdAsync(int id); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/LoteDeEnvioRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/LoteDeEnvioRepository.cs new file mode 100644 index 0000000..dcf45e7 --- /dev/null +++ b/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/LoteDeEnvioRepository.cs @@ -0,0 +1,69 @@ +using System.Text; +using Dapper; +using GestionIntegral.Api.Models.Comunicaciones; + +namespace GestionIntegral.Api.Data.Repositories.Comunicaciones +{ + public class LoteDeEnvioRepository : ILoteDeEnvioRepository + { + private readonly DbConnectionFactory _connectionFactory; + public LoteDeEnvioRepository(DbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task CreateAsync(LoteDeEnvio lote) + { + const string sql = @" + INSERT INTO dbo.com_LotesDeEnvio (FechaInicio, Periodo, Origen, Estado, IdUsuarioDisparo) + OUTPUT INSERTED.* + VALUES (@FechaInicio, @Periodo, @Origen, @Estado, @IdUsuarioDisparo);"; + using var connection = _connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync(sql, lote); + } + + public async Task UpdateAsync(LoteDeEnvio lote) + { + const string sql = @" + UPDATE dbo.com_LotesDeEnvio SET + FechaFin = @FechaFin, + Estado = @Estado, + TotalCorreos = @TotalCorreos, + TotalEnviados = @TotalEnviados, + TotalFallidos = @TotalFallidos + WHERE IdLoteDeEnvio = @IdLoteDeEnvio;"; + using var connection = _connectionFactory.CreateConnection(); + var rows = await connection.ExecuteAsync(sql, lote); + return rows == 1; + } + + public async Task> GetAllAsync(int? anio, int? mes) + { + var sqlBuilder = new StringBuilder("SELECT * FROM dbo.com_LotesDeEnvio WHERE 1=1"); + var parameters = new DynamicParameters(); + + if (anio.HasValue) + { + sqlBuilder.Append(" AND YEAR(FechaInicio) = @Anio"); + parameters.Add("Anio", anio.Value); + } + if (mes.HasValue) + { + sqlBuilder.Append(" AND MONTH(FechaInicio) = @Mes"); + parameters.Add("Mes", mes.Value); + } + + sqlBuilder.Append(" ORDER BY FechaInicio DESC;"); + + using var connection = _connectionFactory.CreateConnection(); + return await connection.QueryAsync(sqlBuilder.ToString(), parameters); + } + + public async Task GetByIdAsync(int id) + { + const string sql = "SELECT * FROM dbo.com_LotesDeEnvio WHERE IdLoteDeEnvio = @Id;"; + using var connection = _connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Reportes/IReportesRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Reportes/IReportesRepository.cs index fa079a0..9419497 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Reportes/IReportesRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Reportes/IReportesRepository.cs @@ -46,6 +46,7 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes Task> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); Task> GetReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); Task> GetDatosReportePublicidadAsync(string periodo); - Task> GetDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta); + Task> GetDistribucionSuscripcionesActivasAsync(DateTime fechaDesde, DateTime fechaHasta); + Task> GetDistribucionSuscripcionesBajasAsync(DateTime fechaDesde, DateTime fechaHasta); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Reportes/ReportesRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Reportes/ReportesRepository.cs index a47ea65..a9aa80e 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Reportes/ReportesRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Reportes/ReportesRepository.cs @@ -593,33 +593,25 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes } } - public async Task> GetDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta) + public async Task> GetDistribucionSuscripcionesActivasAsync(DateTime fechaDesde, DateTime fechaHasta) { const string sql = @" SELECT - e.Nombre AS NombreEmpresa, - p.Nombre AS NombrePublicacion, - sus.NombreCompleto AS NombreSuscriptor, - sus.Direccion, - sus.Telefono, - s.FechaInicio, - s.FechaFin, - s.DiasEntrega, - s.Observaciones + e.Nombre AS NombreEmpresa, p.Nombre AS NombrePublicacion, + sus.NombreCompleto AS NombreSuscriptor, sus.Direccion, sus.Telefono, + s.FechaInicio, s.FechaFin, s.DiasEntrega, s.Observaciones FROM dbo.susc_Suscripciones s JOIN dbo.susc_Suscriptores sus ON s.IdSuscriptor = sus.IdSuscriptor JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion JOIN dbo.dist_dtEmpresas e ON p.Id_Empresa = e.Id_Empresa WHERE - s.Estado = 'Activa' - AND sus.Activo = 1 - -- La suscripción debe haber comenzado ANTES de que termine el rango de fechas + -- --- INICIO DE LA CORRECCIÓN --- + -- Se asegura de que SOLO se incluyan suscripciones y suscriptores ACTIVOS. + s.Estado = 'Activa' AND sus.Activo = 1 + -- --- FIN DE LA CORRECCIÓN --- AND s.FechaInicio <= @FechaHasta - -- Y debe terminar DESPUÉS de que comience el rango de fechas (o no tener fecha de fin) AND (s.FechaFin IS NULL OR s.FechaFin >= @FechaDesde) - ORDER BY - e.Nombre, p.Nombre, sus.NombreCompleto; - "; + ORDER BY e.Nombre, p.Nombre, sus.NombreCompleto;"; try { @@ -628,7 +620,36 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes } catch (Exception ex) { - _logger.LogError(ex, "Error al obtener datos para el Reporte de Distribución de Suscripciones."); + _logger.LogError(ex, "Error al obtener datos para Reporte de Distribución (Activas)."); + return Enumerable.Empty(); + } + } + + public async Task> GetDistribucionSuscripcionesBajasAsync(DateTime fechaDesde, DateTime fechaHasta) + { + const string sql = @" + SELECT + e.Nombre AS NombreEmpresa, p.Nombre AS NombrePublicacion, + sus.NombreCompleto AS NombreSuscriptor, sus.Direccion, sus.Telefono, + s.FechaInicio, s.FechaFin, s.DiasEntrega, s.Observaciones + FROM dbo.susc_Suscripciones s + JOIN dbo.susc_Suscriptores sus ON s.IdSuscriptor = sus.IdSuscriptor + JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion + JOIN dbo.dist_dtEmpresas e ON p.Id_Empresa = e.Id_Empresa + WHERE + -- La lógica aquí es correcta: buscamos cualquier suscripción cuya fecha de fin + -- caiga dentro del rango de fechas seleccionado. + s.FechaFin BETWEEN @FechaDesde AND @FechaHasta + ORDER BY e.Nombre, p.Nombre, s.FechaFin, sus.NombreCompleto;"; + + try + { + using var connection = _dbConnectionFactory.CreateConnection(); + return await connection.QueryAsync(sql, new { FechaDesde = fechaDesde, FechaHasta = fechaHasta }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al obtener datos para Reporte de Distribución (Bajas)."); return Enumerable.Empty(); } } diff --git a/Backend/GestionIntegral.Api/Models/Comunicaciones/EmailLog.cs b/Backend/GestionIntegral.Api/Models/Comunicaciones/EmailLog.cs index fd78b8e..ad14a3c 100644 --- a/Backend/GestionIntegral.Api/Models/Comunicaciones/EmailLog.cs +++ b/Backend/GestionIntegral.Api/Models/Comunicaciones/EmailLog.cs @@ -11,5 +11,6 @@ namespace GestionIntegral.Api.Models.Comunicaciones public int? IdUsuarioDisparo { get; set; } public string? Origen { get; set; } public string? ReferenciaId { get; set; } + public int? IdLoteDeEnvio { get; set; } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Comunicaciones/LoteDeEnvio.cs b/Backend/GestionIntegral.Api/Models/Comunicaciones/LoteDeEnvio.cs new file mode 100644 index 0000000..1ca8628 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Comunicaciones/LoteDeEnvio.cs @@ -0,0 +1,16 @@ +namespace GestionIntegral.Api.Models.Comunicaciones +{ + public class LoteDeEnvio + { + public int IdLoteDeEnvio { get; set; } + public DateTime FechaInicio { get; set; } + public DateTime? FechaFin { get; set; } + public string Periodo { get; set; } = string.Empty; + public string Origen { get; set; } = string.Empty; + public string Estado { get; set; } = string.Empty; + public int TotalCorreos { get; set; } + public int TotalEnviados { get; set; } + public int TotalFallidos { get; set; } + public int IdUsuarioDisparo { get; set; } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Comunicaciones/LoteDeEnvioDtos.cs b/Backend/GestionIntegral.Api/Models/Dtos/Comunicaciones/LoteDeEnvioDtos.cs new file mode 100644 index 0000000..b1b4318 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Comunicaciones/LoteDeEnvioDtos.cs @@ -0,0 +1,26 @@ +namespace GestionIntegral.Api.Dtos.Comunicaciones +{ + // DTO para el feedback inmediato + public class LoteDeEnvioResumenDto + { + public int IdLoteDeEnvio { get; set; } + public string Periodo { get; set; } = string.Empty; + public int TotalCorreos { get; set; } + public int TotalEnviados { get; set; } + public int TotalFallidos { get; set; } + public List ErroresDetallados { get; set; } = new(); + } + + // DTO para la tabla de historial + public class LoteDeEnvioHistorialDto + { + public int IdLoteDeEnvio { get; set; } + public DateTime FechaInicio { get; set; } + public string Periodo { get; set; } = string.Empty; + public string Estado { get; set; } = string.Empty; + public int TotalCorreos { get; set; } + public int TotalEnviados { get; set; } + public int TotalFallidos { get; set; } + public string NombreUsuarioDisparo { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Comunicaciones/LoteDeEnvioResumenDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Comunicaciones/LoteDeEnvioResumenDto.cs new file mode 100644 index 0000000..9608889 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Comunicaciones/LoteDeEnvioResumenDto.cs @@ -0,0 +1,11 @@ +using GestionIntegral.Api.Dtos.Comunicaciones; + +public class LoteDeEnvioResumenDto +{ + public int IdLoteDeEnvio { get; set; } + public required string Periodo { get; set; } + public int TotalCorreos { get; set; } + public int TotalEnviados { get; set; } + public int TotalFallidos { get; set; } + public List ErroresDetallados { get; set; } = new(); +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/DistribucionSuscripcionesViewModel.cs b/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/DistribucionSuscripcionesViewModel.cs index 38fb329..5c80cbf 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/DistribucionSuscripcionesViewModel.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/DistribucionSuscripcionesViewModel.cs @@ -1,12 +1,17 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels { - // Clases internas para la agrupación + /// + /// Representa una agrupación de suscripciones por publicación para el reporte. + /// public class GrupoPublicacion { public string NombrePublicacion { get; set; } = string.Empty; public IEnumerable Suscripciones { get; set; } = Enumerable.Empty(); } + /// + /// Representa una agrupación de publicaciones por empresa para el reporte. + /// public class GrupoEmpresa { public string NombreEmpresa { get; set; } = string.Empty; @@ -15,28 +20,36 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels public class DistribucionSuscripcionesViewModel { - public IEnumerable DatosAgrupados { get; } + public IEnumerable DatosAgrupadosAltas { get; } + public IEnumerable DatosAgrupadosBajas { get; } public string FechaDesde { get; set; } = string.Empty; public string FechaHasta { get; set; } = string.Empty; public string FechaGeneracion { get; set; } = string.Empty; - public DistribucionSuscripcionesViewModel(IEnumerable suscripciones) + public DistribucionSuscripcionesViewModel(IEnumerable altas, IEnumerable bajas) { - DatosAgrupados = suscripciones - .GroupBy(s => s.NombreEmpresa) - .Select(gEmpresa => new GrupoEmpresa - { - NombreEmpresa = gEmpresa.Key, - Publicaciones = gEmpresa - .GroupBy(s => s.NombrePublicacion) - .Select(gPub => new GrupoPublicacion - { - NombrePublicacion = gPub.Key, - Suscripciones = gPub.OrderBy(s => s.NombreSuscriptor).ToList() - }) - .OrderBy(p => p.NombrePublicacion) - }) - .OrderBy(e => e.NombreEmpresa); + // Función local para evitar repetir el código de agrupación + Func, IEnumerable> agruparDatos = (suscripciones) => + { + return suscripciones + .GroupBy(s => s.NombreEmpresa) + .Select(gEmpresa => new GrupoEmpresa + { + NombreEmpresa = gEmpresa.Key, + Publicaciones = gEmpresa + .GroupBy(s => s.NombrePublicacion) + .Select(gPub => new GrupoPublicacion + { + NombrePublicacion = gPub.Key, + Suscripciones = gPub.OrderBy(s => s.NombreSuscriptor).ToList() + }) + .OrderBy(p => p.NombrePublicacion) + }) + .OrderBy(e => e.NombreEmpresa); + }; + + DatosAgrupadosAltas = agruparDatos(altas); + DatosAgrupadosBajas = agruparDatos(bajas); } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Program.cs b/Backend/GestionIntegral.Api/Program.cs index f58f5c6..064ad81 100644 --- a/Backend/GestionIntegral.Api/Program.cs +++ b/Backend/GestionIntegral.Api/Program.cs @@ -130,6 +130,7 @@ builder.Services.Configure(builder.Configuration.GetSection("MailS builder.Services.AddTransient(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // --- SERVICIO DE HEALTH CHECKS --- // Añadimos una comprobación específica para SQL Server. diff --git a/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailLogService.cs b/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailLogService.cs index ada0d36..0ad69d8 100644 --- a/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailLogService.cs +++ b/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailLogService.cs @@ -49,5 +49,39 @@ namespace GestionIntegral.Api.Services.Comunicaciones : "Sistema" }); } + + public async Task> ObtenerDetallesPorLoteId(int idLoteDeEnvio) + { + var logs = await _emailLogRepository.GetByLoteIdAsync(idLoteDeEnvio); + if (!logs.Any()) + { + return Enumerable.Empty(); + } + + // 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(); + 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" + }); + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs b/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs index 31bd8ae..764692d 100644 --- a/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs +++ b/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs @@ -16,7 +16,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones public EmailService( IOptions mailSettings, ILogger 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); } - /// - /// Método privado que centraliza el envío de correo y el registro de logs. - /// - 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); diff --git a/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailLogService.cs b/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailLogService.cs index c5bd692..c0371a3 100644 --- a/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailLogService.cs +++ b/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailLogService.cs @@ -5,5 +5,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones public interface IEmailLogService { Task> ObtenerHistorialPorReferencia(string referenciaId); + Task> ObtenerDetallesPorLoteId(int idLoteDeEnvio); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs b/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs index 03cbd33..bae954d 100644 --- a/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs +++ b/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs @@ -15,6 +15,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones /// Identificador del proceso que dispara el email (ej. "EnvioManualPDF"). Para logging. /// ID de la entidad relacionada (ej. "Factura-59"). Para logging. /// ID del usuario que inició la acción (si aplica). Para logging. + /// ID del lote de envío masivo al que pertenece este correo (si aplica). Para logging. 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); /// /// 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 /// Identificador del proceso que dispara el email (ej. "FacturacionMensual"). Para logging. /// ID de la entidad relacionada (ej. "Suscriptor-3"). Para logging. /// ID del usuario que inició la acción (si aplica). Para logging. + /// ID del lote de envío masivo al que pertenece este correo (si aplica). Para logging. 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); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Reportes/IReportesService.cs b/Backend/GestionIntegral.Api/Services/Reportes/IReportesService.cs index 60c0a71..ff2975a 100644 --- a/Backend/GestionIntegral.Api/Services/Reportes/IReportesService.cs +++ b/Backend/GestionIntegral.Api/Services/Reportes/IReportesService.cs @@ -66,6 +66,6 @@ namespace GestionIntegral.Api.Services.Reportes Task<(IEnumerable Data, string? Error)> ObtenerReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); Task<(IEnumerable Data, string? Error)> ObtenerReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); Task<(IEnumerable Data, string? Error)> ObtenerFacturasParaReportePublicidad(int anio, int mes); - Task<(IEnumerable Data, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta); + Task<(IEnumerable Altas, IEnumerable Bajas, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs b/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs index 5fdb539..43282c2 100644 --- a/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs +++ b/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs @@ -551,21 +551,27 @@ namespace GestionIntegral.Api.Services.Reportes } } - public async Task<(IEnumerable Data, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta) + public async Task<(IEnumerable Altas, IEnumerable Bajas, string? Error)> ObtenerReporteDistribucionSuscripcionesAsync(DateTime fechaDesde, DateTime fechaHasta) { if (fechaDesde > fechaHasta) { - return (Enumerable.Empty(), "La fecha 'Desde' no puede ser mayor que la fecha 'Hasta'."); + return (Enumerable.Empty(), Enumerable.Empty(), "La fecha 'Desde' no puede ser mayor que la fecha 'Hasta'."); } + try { - var data = await _reportesRepository.GetDistribucionSuscripcionesAsync(fechaDesde, fechaHasta); - return (data, null); + // Ejecutamos ambas consultas en paralelo para mayor eficiencia + var altasTask = _reportesRepository.GetDistribucionSuscripcionesActivasAsync(fechaDesde, fechaHasta); + var bajasTask = _reportesRepository.GetDistribucionSuscripcionesBajasAsync(fechaDesde, fechaHasta); + + await Task.WhenAll(altasTask, bajasTask); + + return (await altasTask, await bajasTask, null); } catch (Exception ex) { _logger.LogError(ex, "Error en servicio al obtener datos para reporte de distribución de suscripciones."); - return (new List(), "Error interno al generar el reporte."); + return (Enumerable.Empty(), Enumerable.Empty(), "Error interno al generar el reporte."); } } } diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs index 06d73ec..1386387 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs @@ -4,15 +4,21 @@ using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Dtos.Suscripciones; using GestionIntegral.Api.Models.Distribucion; using GestionIntegral.Api.Models.Suscripciones; -using GestionIntegral.Api.Services.Comunicaciones; using System.Data; using System.Globalization; using System.Text; +using GestionIntegral.Api.Services.Comunicaciones; +using GestionIntegral.Api.Data.Repositories.Comunicaciones; +using GestionIntegral.Api.Data.Repositories.Usuarios; +using GestionIntegral.Api.Dtos.Comunicaciones; +using GestionIntegral.Api.Models.Comunicaciones; namespace GestionIntegral.Api.Services.Suscripciones { public class FacturacionService : IFacturacionService { + private readonly ILoteDeEnvioRepository _loteDeEnvioRepository; + private readonly IUsuarioRepository _usuarioRepository; private readonly ISuscripcionRepository _suscripcionRepository; private readonly IFacturaRepository _facturaRepository; private readonly IEmpresaRepository _empresaRepository; @@ -41,8 +47,12 @@ namespace GestionIntegral.Api.Services.Suscripciones IPublicacionRepository publicacionRepository, DbConnectionFactory connectionFactory, ILogger logger, - IConfiguration configuration) + IConfiguration configuration, + ILoteDeEnvioRepository loteDeEnvioRepository, + IUsuarioRepository usuarioRepository) { + _loteDeEnvioRepository = loteDeEnvioRepository; + _usuarioRepository = usuarioRepository; _suscripcionRepository = suscripcionRepository; _facturaRepository = facturaRepository; _empresaRepository = empresaRepository; @@ -58,12 +68,23 @@ namespace GestionIntegral.Api.Services.Suscripciones _facturasPdfPath = configuration.GetValue("AppSettings:FacturasPdfPath") ?? "C:\\FacturasPDF"; } - public async Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario) + public async Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario) { var periodoActual = new DateTime(anio, mes, 1); var periodoActualStr = periodoActual.ToString("yyyy-MM"); _logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodoActualStr, idUsuario); + // --- INICIO: Creación del Lote de Envío --- + var lote = await _loteDeEnvioRepository.CreateAsync(new LoteDeEnvio + { + FechaInicio = DateTime.Now, + Periodo = periodoActualStr, + Origen = "FacturacionMensual", + Estado = "Iniciado", + IdUsuarioDisparo = idUsuario + }); + // --- FIN: Creación del Lote de Envío --- + var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync(); if (ultimoPeriodoFacturadoStr != null) { @@ -71,11 +92,16 @@ namespace GestionIntegral.Api.Services.Suscripciones if (periodoActual != ultimoPeriodo.AddMonths(1)) { var periodoEsperado = ultimoPeriodo.AddMonths(1).ToString("MMMM 'de' yyyy", new CultureInfo("es-ES")); - return (false, $"Error: No se puede generar la facturación de {periodoActual:MMMM 'de' yyyy}. El siguiente período a generar es {periodoEsperado}.", 0); + return (false, $"Error: No se puede generar la facturación de {periodoActual:MMMM 'de' yyyy}. El siguiente período a generar es {periodoEsperado}.", null); } } var facturasCreadas = new List(); + int facturasGeneradas = 0; + int emailsEnviados = 0; + int emailsFallidos = 0; + var erroresDetallados = new List(); + using var connection = _connectionFactory.CreateConnection(); await (connection as System.Data.Common.DbConnection)!.OpenAsync(); using var transaction = connection.BeginTransaction(); @@ -85,10 +111,13 @@ namespace GestionIntegral.Api.Services.Suscripciones var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodoActualStr, transaction); if (!suscripcionesActivas.Any()) { - return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0); + // Si no hay nada que facturar, consideramos el proceso exitoso pero sin resultados. + lote.Estado = "Completado"; + lote.FechaFin = DateTime.Now; + await _loteDeEnvioRepository.UpdateAsync(lote); + return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", null); } - // 1. Enriquecer las suscripciones con el IdEmpresa de su publicación var suscripcionesConEmpresa = new List<(Suscripcion Suscripcion, int IdEmpresa)>(); foreach (var s in suscripcionesActivas) { @@ -99,30 +128,23 @@ namespace GestionIntegral.Api.Services.Suscripciones } } - // 2. Agrupar por la combinación (Suscriptor, Empresa) var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa }); - int facturasGeneradas = 0; foreach (var grupo in gruposParaFacturar) { int idSuscriptor = grupo.Key.IdSuscriptor; - int idEmpresa = grupo.Key.IdEmpresa; // <-- Ya tenemos la empresa del grupo - + int idEmpresa = grupo.Key.IdEmpresa; decimal importeBrutoTotal = 0; decimal descuentoPromocionesTotal = 0; var detallesParaFactura = new List(); - - // 3. Calcular el importe para cada suscripción DENTRO del grupo foreach (var item in grupo) { var suscripcion = item.Suscripcion; decimal importeBrutoSusc = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction); var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, periodoActual, transaction); decimal descuentoSusc = CalcularDescuentoPromociones(importeBrutoSusc, promociones); - importeBrutoTotal += importeBrutoSusc; descuentoPromocionesTotal += descuentoSusc; - var publicacion = await _publicacionRepository.GetByIdSimpleAsync(suscripcion.IdPublicacion); detallesParaFactura.Add(new FacturaDetalle { @@ -133,24 +155,12 @@ namespace GestionIntegral.Api.Services.Suscripciones ImporteNeto = importeBrutoSusc - descuentoSusc }); } - - // 4. Aplicar ajustes. Ahora se buscan por Suscriptor Y por Empresa. var ultimoDiaDelMes = periodoActual.AddMonths(1).AddDays(-1); var ajustesPendientes = await _ajusteRepository.GetAjustesPendientesHastaFechaAsync(idSuscriptor, idEmpresa, ultimoDiaDelMes, transaction); decimal totalAjustes = ajustesPendientes.Sum(a => a.TipoAjuste == "Credito" ? -a.Monto : a.Monto); - - // Verificamos si este grupo es el "primero" para este cliente para no aplicar ajustes varias veces - bool esPrimerGrupoParaCliente = !facturasCreadas.Any(f => f.IdSuscriptor == idSuscriptor); - if (esPrimerGrupoParaCliente) - { - totalAjustes = ajustesPendientes.Sum(a => a.TipoAjuste == "Credito" ? -a.Monto : a.Monto); - } - var importeFinal = importeBrutoTotal - descuentoPromocionesTotal + totalAjustes; if (importeFinal < 0) importeFinal = 0; if (importeBrutoTotal <= 0 && descuentoPromocionesTotal <= 0 && totalAjustes == 0) continue; - - // 5. Crear UNA factura por cada grupo (Suscriptor + Empresa) var nuevaFactura = new Factura { IdSuscriptor = idSuscriptor, @@ -163,60 +173,109 @@ namespace GestionIntegral.Api.Services.Suscripciones EstadoPago = "Pendiente", EstadoFacturacion = "Pendiente de Facturar" }; - var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction); if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}"); - facturasCreadas.Add(facturaCreada); - foreach (var detalle in detallesParaFactura) { detalle.IdFactura = facturaCreada.IdFactura; await _facturaDetalleRepository.CreateAsync(detalle, transaction); } - if (ajustesPendientes.Any()) { await _ajusteRepository.MarcarAjustesComoAplicadosAsync(ajustesPendientes.Select(a => a.IdAjuste), facturaCreada.IdFactura, transaction); } facturasGeneradas++; } - // --- FIN DE LA LÓGICA DE AGRUPACIÓN --- transaction.Commit(); _logger.LogInformation("Finalizada la generación de {FacturasGeneradas} facturas para {Periodo}.", facturasGeneradas, periodoActualStr); - // --- INICIO DE LA LÓGICA DE ENVÍO CONSOLIDADO AUTOMÁTICO --- - int emailsEnviados = 0; if (facturasCreadas.Any()) { - // Agrupamos las facturas creadas por suscriptor para enviar un solo email var suscriptoresAnotificar = facturasCreadas.Select(f => f.IdSuscriptor).Distinct().ToList(); _logger.LogInformation("Iniciando envío automático de avisos para {Count} suscriptores.", suscriptoresAnotificar.Count); foreach (var idSuscriptor in suscriptoresAnotificar) { + var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor); // Necesitamos el objeto suscriptor + if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) + { + emailsFallidos++; + erroresDetallados.Add(new EmailLogDto { DestinatarioEmail = suscriptor?.NombreCompleto ?? $"ID Suscriptor {idSuscriptor}", Error = "Suscriptor sin email válido." }); + continue; + } + try { - var (envioExitoso, _) = await EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor); - if (envioExitoso) emailsEnviados++; + await EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor, lote.IdLoteDeEnvio, idUsuario); + emailsEnviados++; } catch (Exception exEmail) { + emailsFallidos++; + erroresDetallados.Add(new EmailLogDto { DestinatarioEmail = suscriptor.Email, Error = exEmail.Message }); _logger.LogError(exEmail, "Falló el envío automático de email para el suscriptor ID {IdSuscriptor}", idSuscriptor); } } _logger.LogInformation("{EmailsEnviados} avisos de vencimiento enviados automáticamente.", emailsEnviados); } - return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas y se enviaron {emailsEnviados} notificaciones.", facturasGeneradas); + lote.Estado = "Completado"; } catch (Exception ex) { try { transaction.Rollback(); } catch { } + lote.Estado = "Fallido"; _logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodoActualStr); - return (false, "Error interno del servidor al generar la facturación.", 0); + return (false, "Error interno del servidor al generar la facturación.", null); } + finally + { + lote.FechaFin = DateTime.Now; + lote.TotalCorreos = emailsEnviados + emailsFallidos; + lote.TotalEnviados = emailsEnviados; + lote.TotalFallidos = emailsFallidos; + await _loteDeEnvioRepository.UpdateAsync(lote); + } + + var resultadoEnvio = new LoteDeEnvioResumenDto + { + IdLoteDeEnvio = lote.IdLoteDeEnvio, + Periodo = periodoActualStr, + TotalCorreos = lote.TotalCorreos, + TotalEnviados = lote.TotalEnviados, + TotalFallidos = lote.TotalFallidos, + ErroresDetallados = erroresDetallados + }; + + return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", resultadoEnvio); + } + + public async Task> ObtenerHistorialLotesEnvio(int? anio, int? mes) + { + var lotes = await _loteDeEnvioRepository.GetAllAsync(anio, mes); + if (!lotes.Any()) + { + return Enumerable.Empty(); + } + + var idsUsuarios = lotes.Select(l => l.IdUsuarioDisparo).Distinct(); + var usuarios = (await _usuarioRepository.GetByIdsAsync(idsUsuarios)).ToDictionary(u => u.Id); + + return lotes.Select(l => new LoteDeEnvioHistorialDto + { + IdLoteDeEnvio = l.IdLoteDeEnvio, + FechaInicio = l.FechaInicio, + Periodo = l.Periodo, + Estado = l.Estado, + TotalCorreos = l.TotalCorreos, + TotalEnviados = l.TotalEnviados, + TotalFallidos = l.TotalFallidos, + NombreUsuarioDisparo = usuarios.TryGetValue(l.IdUsuarioDisparo, out var user) + ? $"{user.Nombre} {user.Apellido}" + : "Usuario Desconocido" + }); } public async Task> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) @@ -317,91 +376,101 @@ namespace GestionIntegral.Api.Services.Suscripciones } } - public async Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor) + /// + /// Construye y envía un email consolidado con el resumen de todas las facturas de un suscriptor para un período. + /// Este método está diseñado para ser llamado desde un proceso masivo como la facturación mensual. + /// + private async Task EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor, int idLoteDeEnvio, int idUsuarioDisparo) { var periodo = $"{anio}-{mes:D2}"; - try + + // La lógica de try/catch ahora está en el método llamador (GenerarFacturacionMensual) + // para poder contar los fallos y actualizar el lote de envío. + + var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo); + if (!facturasConEmpresa.Any()) { - var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo); - if (!facturasConEmpresa.Any()) return (false, "No se encontraron facturas para este suscriptor en el período."); + // Si no hay facturas, no hay nada que enviar. Esto no debería ocurrir si se llama desde GenerarFacturacionMensual. + _logger.LogWarning("Se intentó enviar aviso para Suscriptor ID {IdSuscriptor} en período {Periodo}, pero no se encontraron facturas.", idSuscriptor, periodo); + return; + } - var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor); - if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email."); + var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor); + // La validación de si el suscriptor tiene email ya se hace en el método llamador. + if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) + { + // Lanzamos una excepción para que el método llamador la capture y la cuente como un fallo. + throw new InvalidOperationException($"El suscriptor ID {idSuscriptor} no es válido o no tiene una dirección de email registrada."); + } - var resumenHtml = new StringBuilder(); - var adjuntos = new List<(byte[] content, string name)>(); + var resumenHtml = new StringBuilder(); + var adjuntos = new List<(byte[] content, string name)>(); - foreach (var item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada")) + foreach (var item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada")) + { + var factura = item.Factura; + var nombreEmpresa = item.NombreEmpresa; + var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura); + + resumenHtml.Append($"

Resumen para {nombreEmpresa}

"); + resumenHtml.Append(""); + + foreach (var detalle in detalles) { - var factura = item.Factura; - var nombreEmpresa = item.NombreEmpresa; + resumenHtml.Append($""); + } - var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura); - - resumenHtml.Append($"

Resumen para {nombreEmpresa}

"); - resumenHtml.Append("
{detalle.Descripcion}${detalle.ImporteNeto:N2}
"); - - foreach (var detalle in detalles) + var ajustes = await _ajusteRepository.GetAjustesPorIdFacturaAsync(factura.IdFactura); + if (ajustes.Any()) + { + foreach (var ajuste in ajustes) { - resumenHtml.Append($""); - } - - var ajustes = await _ajusteRepository.GetAjustesPorIdFacturaAsync(factura.IdFactura); - if (ajustes.Any()) - { - foreach (var ajuste in ajustes) - { - bool esCredito = ajuste.TipoAjuste == "Credito"; - string colorMonto = esCredito ? "#5cb85c" : "#d9534f"; - string signo = esCredito ? "-" : "+"; - resumenHtml.Append($""); - } - } - - resumenHtml.Append($""); - resumenHtml.Append("
{detalle.Descripcion}${detalle.ImporteNeto:N2}
Ajuste: {ajuste.Motivo}{signo} ${ajuste.Monto:N2}
Subtotal${factura.ImporteFinal:N2}
"); - - if (!string.IsNullOrEmpty(factura.NumeroFactura)) - { - var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf"); - if (File.Exists(rutaCompleta)) - { - byte[] pdfBytes = await File.ReadAllBytesAsync(rutaCompleta); - string pdfFileName = $"Factura_{nombreEmpresa.Replace(" ", "")}_{factura.NumeroFactura}.pdf"; - adjuntos.Add((pdfBytes, pdfFileName)); - _logger.LogInformation("PDF adjuntado: {FileName}", pdfFileName); - } - else - { - _logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta); - } + bool esCredito = ajuste.TipoAjuste == "Credito"; + string colorMonto = esCredito ? "#5cb85c" : "#d9534f"; + string signo = esCredito ? "-" : "+"; + resumenHtml.Append($"Ajuste: {ajuste.Motivo}{signo} ${ajuste.Monto:N2}"); } } - var totalGeneral = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.ImporteFinal); - string asunto = $"Resumen de Cuenta - Período {periodo}"; - string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral); + resumenHtml.Append($"Subtotal${factura.ImporteFinal:N2}"); + resumenHtml.Append(""); - // Añadir los parámetros de contexto aquí también - await _emailService.EnviarEmailConsolidadoAsync( - destinatarioEmail: suscriptor.Email, - destinatarioNombre: suscriptor.NombreCompleto, - asunto: asunto, - cuerpoHtml: cuerpoHtml, - adjuntos: adjuntos, - origen: "FacturacionMensual", - referenciaId: $"Suscriptor-{idSuscriptor}", - idUsuarioDisparo: null); // Es null porque es un proceso automático del sistema + if (!string.IsNullOrEmpty(factura.NumeroFactura)) + { + var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf"); + if (File.Exists(rutaCompleta)) + { + byte[] pdfBytes = await File.ReadAllBytesAsync(rutaCompleta); + string pdfFileName = $"Factura_{nombreEmpresa.Replace(" ", "")}_{factura.NumeroFactura}.pdf"; + adjuntos.Add((pdfBytes, pdfFileName)); + _logger.LogInformation("PDF adjuntado para envío a Suscriptor ID {IdSuscriptor}: {FileName}", idSuscriptor, pdfFileName); + } + else + { + _logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta); + } + } + } - await _emailService.EnviarEmailConsolidadoAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, adjuntos); - _logger.LogInformation("Email consolidado para Suscriptor ID {IdSuscriptor} enviado para el período {Periodo}.", idSuscriptor, periodo); - return (true, null); - } - catch (Exception ex) - { - _logger.LogError(ex, "Falló el envío de email consolidado para el suscriptor ID {IdSuscriptor} y período {Periodo}", idSuscriptor, periodo); - return (false, "Ocurrió un error al intentar enviar el email consolidado."); - } + var totalGeneral = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.ImporteFinal); + string asunto = $"Resumen de Cuenta - Período {periodo}"; + string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral); + + await _emailService.EnviarEmailConsolidadoAsync( + destinatarioEmail: suscriptor.Email, + destinatarioNombre: suscriptor.NombreCompleto, + asunto: asunto, + cuerpoHtml: cuerpoHtml, + adjuntos: adjuntos, + origen: "FacturacionMensual", + referenciaId: $"Suscriptor-{idSuscriptor}", + idUsuarioDisparo: idUsuarioDisparo, // Se pasa el ID del usuario que inició el cierre + idLoteDeEnvio: idLoteDeEnvio // Se pasa el ID del lote + ); + + // El logging de éxito o fallo ahora lo hace el EmailService, por lo que este log ya no es estrictamente necesario, + // pero lo mantenemos para tener un registro de alto nivel en el log del FacturacionService. + _logger.LogInformation("Llamada a EmailService completada para Suscriptor ID {IdSuscriptor} en el período {Periodo}.", idSuscriptor, periodo); } private string ConstruirCuerpoEmailConsolidado(Suscriptor suscriptor, string periodo, string resumenHtml, decimal totalGeneral) diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs index c5fff1d..fc57241 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs @@ -1,14 +1,13 @@ +using GestionIntegral.Api.Dtos.Comunicaciones; using GestionIntegral.Api.Dtos.Suscripciones; -using System.Collections.Generic; -using System.Threading.Tasks; namespace GestionIntegral.Api.Services.Suscripciones { public interface IFacturacionService { - Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); + Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); + Task> ObtenerHistorialLotesEnvio(int? anio, int? mes); Task> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); - Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor); Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario); Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario); } diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs index c64eaec..4fd6e4f 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs @@ -92,6 +92,15 @@ namespace GestionIntegral.Api.Services.Suscripciones return (null, "La publicación no existe."); if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio) return (null, "La fecha de fin no puede ser anterior a la fecha de inicio."); + if ((createDto.Estado == "Cancelada" || createDto.Estado == "Pausada") && !createDto.FechaFin.HasValue) + { + return (null, "Se debe especificar una 'Fecha Fin' cuando el estado es 'Cancelada' o 'Pausada'."); + } + + if (createDto.Estado == "Activa") + { + createDto.FechaFin = null; + } var nuevaSuscripcion = new Suscripcion { @@ -130,6 +139,18 @@ namespace GestionIntegral.Api.Services.Suscripciones var existente = await _suscripcionRepository.GetByIdAsync(idSuscripcion); if (existente == null) return (false, "Suscripción no encontrada."); + // Validación de lógica de negocio en el backend + if ((updateDto.Estado == "Cancelada" || updateDto.Estado == "Pausada") && !updateDto.FechaFin.HasValue) + { + return (false, "Se debe especificar una 'Fecha Fin' cuando el estado es 'Cancelada' o 'Pausada'."); + } + + // Si el estado es 'Activa', nos aseguramos de que la FechaFin sea nula. + if (updateDto.Estado == "Activa") + { + updateDto.FechaFin = null; + } + if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio) return (false, "La fecha de fin no puede ser anterior a la fecha de inicio."); diff --git a/Frontend/src/components/Modals/Suscripciones/ResultadoEnvioModal.tsx b/Frontend/src/components/Modals/Suscripciones/ResultadoEnvioModal.tsx new file mode 100644 index 0000000..ac69407 --- /dev/null +++ b/Frontend/src/components/Modals/Suscripciones/ResultadoEnvioModal.tsx @@ -0,0 +1,88 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { Box, Dialog, DialogTitle, DialogContent, IconButton, Tabs, Tab, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Paper, Chip, CircularProgress, Alert } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import type { EmailLogDto } from '../../../models/dtos/Comunicaciones/EmailLogDto'; +import facturacionService from '../../../services/Suscripciones/facturacionService'; + +interface ResultadoEnvioModalProps { + open: boolean; + onClose: () => void; + loteId: number | null; + periodo: string; +} + +const ResultadoEnvioModal: React.FC = ({ open, onClose, loteId, periodo }) => { + const [activeTab, setActiveTab] = useState(0); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open && loteId) { + const fetchDetails = async () => { + setLoading(true); + setError(null); + try { + const data = await facturacionService.getDetallesLoteEnvio(loteId); + setLogs(data); + } catch (err) { + setError('No se pudieron cargar los detalles del envío.'); + } finally { + setLoading(false); + } + }; + fetchDetails(); + } + }, [open, loteId]); + + const filteredLogs = useMemo(() => { + if (activeTab === 1) return logs.filter(log => log.estado === 'Enviado'); + if (activeTab === 2) return logs.filter(log => log.estado === 'Fallido'); + return logs; // Tab 0 es 'Todos' + }, [logs, activeTab]); + + return ( + + + Detalle del Lote de Envío - Período {periodo} + + + + + setActiveTab(newValue)}> + + l.estado === 'Enviado').length})`} /> + l.estado === 'Fallido').length})`} /> + + + + {loading ? : error ? {error} : + + + + + Destinatario + Asunto + Estado + Detalle del Error + + + + {filteredLogs.map((log, index) => ( + + {log.destinatarioEmail} + {log.asunto} + + {log.error || '-'} + + ))} + +
+
+ } +
+
+ ); +}; + +export default ResultadoEnvioModal; \ No newline at end of file diff --git a/Frontend/src/components/Modals/Suscripciones/SuscripcionFormModal.tsx b/Frontend/src/components/Modals/Suscripciones/SuscripcionFormModal.tsx index 1e5ba78..bb4091a 100644 --- a/Frontend/src/components/Modals/Suscripciones/SuscripcionFormModal.tsx +++ b/Frontend/src/components/Modals/Suscripciones/SuscripcionFormModal.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from 'react'; import { - Modal, Box, Typography, TextField, Button, CircularProgress, Alert, - FormControl, InputLabel, Select, MenuItem, FormGroup, FormControlLabel, - Checkbox, type SelectChangeEvent, Paper + Modal, Box, Typography, TextField, Button, CircularProgress, Alert, + FormControl, InputLabel, Select, MenuItem, FormGroup, FormControlLabel, + Checkbox, type SelectChangeEvent, Paper } from '@mui/material'; import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto'; import type { CreateSuscripcionDto } from '../../../models/dtos/Suscripciones/CreateSuscripcionDto'; @@ -25,10 +25,10 @@ const modalStyle = { }; const dias = [ - { label: 'Lunes', value: 'Lun' }, { label: 'Martes', value: 'Mar' }, - { label: 'Miércoles', value: 'Mie' }, { label: 'Jueves', value: 'Jue' }, - { label: 'Viernes', value: 'Vie' }, { label: 'Sábado', value: 'Sab' }, - { label: 'Domingo', value: 'Dom' } + { label: 'Lunes', value: 'Lun' }, { label: 'Martes', value: 'Mar' }, + { label: 'Miércoles', value: 'Mie' }, { label: 'Jueves', value: 'Jue' }, + { label: 'Viernes', value: 'Vie' }, { label: 'Sábado', value: 'Sab' }, + { label: 'Domingo', value: 'Dom' } ]; interface SuscripcionFormModalProps { @@ -41,13 +41,12 @@ interface SuscripcionFormModalProps { clearErrorMessage: () => void; } -// Usamos una interfaz local que contenga todos los campos posibles del formulario interface FormState { - idPublicacion?: number | ''; - fechaInicio?: string; - fechaFin?: string | null; - estado?: 'Activa' | 'Pausada' | 'Cancelada'; - observaciones?: string; + idPublicacion?: number | ''; + fechaInicio?: string; + fechaFin?: string | null; + estado?: 'Activa' | 'Pausada' | 'Cancelada'; + observaciones?: string; } const SuscripcionFormModal: React.FC = ({ open, onClose, onSubmit, idSuscriptor, initialData, errorMessage, clearErrorMessage }) => { @@ -57,7 +56,7 @@ const SuscripcionFormModal: React.FC = ({ open, onClo const [loading, setLoading] = useState(false); const [loadingPubs, setLoadingPubs] = useState(false); const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); - + const isEditing = Boolean(initialData); useEffect(() => { @@ -92,8 +91,15 @@ const SuscripcionFormModal: React.FC = ({ open, onClo const errors: { [key: string]: string | null } = {}; if (!formData.idPublicacion) errors.idPublicacion = "Debe seleccionar una publicación."; if (!formData.fechaInicio?.trim()) errors.fechaInicio = 'La Fecha de Inicio es obligatoria.'; + + // --- INICIO DE LA MODIFICACIÓN --- + if (formData.estado !== 'Activa' && !formData.fechaFin) { + errors.fechaFin = 'La Fecha de Fin es obligatoria si el estado es Pausada o Cancelada.'; + } + // --- FIN DE LA MODIFICACIÓN --- + if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) { - errors.fechaFin = 'La Fecha de Fin no puede ser anterior a la de inicio.'; + errors.fechaFin = 'La Fecha de Fin no puede ser anterior a la de inicio.'; } if (selectedDays.size === 0) errors.diasEntrega = "Debe seleccionar al menos un día de entrega."; setLocalErrors(errors); @@ -105,10 +111,10 @@ const SuscripcionFormModal: React.FC = ({ open, onClo if (newSelection.has(dayValue)) newSelection.delete(dayValue); else newSelection.add(dayValue); setSelectedDays(newSelection); - if (localErrors.diasEntrega) setLocalErrors(prev => ({...prev, diasEntrega: null})); + if (localErrors.diasEntrega) setLocalErrors(prev => ({ ...prev, diasEntrega: null })); if (errorMessage) clearErrorMessage(); }; - + const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData(prev => ({ ...prev, [name]: value })); @@ -123,17 +129,38 @@ const SuscripcionFormModal: React.FC = ({ open, onClo if (errorMessage) clearErrorMessage(); }; + // --- INICIO DE LA MODIFICACIÓN --- + const handleEstadoChange = (e: SelectChangeEvent<'Activa' | 'Pausada' | 'Cancelada'>) => { + const nuevoEstado = e.target.value as 'Activa' | 'Pausada' | 'Cancelada'; + const hoy = new Date().toISOString().split('T')[0]; + + setFormData(prev => { + const newState = { ...prev, estado: nuevoEstado }; + + if ((nuevoEstado === 'Cancelada' || nuevoEstado === 'Pausada') && !prev.fechaFin) { + newState.fechaFin = hoy; + } else if (nuevoEstado === 'Activa') { + newState.fechaFin = null; // Limpiar la fecha de fin si se reactiva + } + return newState; + }); + // Limpiar errores al cambiar + if (localErrors.fechaFin) setLocalErrors(prev => ({ ...prev, fechaFin: null })); + if (errorMessage) clearErrorMessage(); + }; + // --- FIN DE LA MODIFICACIÓN --- + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); clearErrorMessage(); if (!validate()) return; - + setLoading(true); let success = false; try { const dataToSubmit = { ...formData, - fechaFin: formData.fechaFin || null, + fechaFin: formData.estado === 'Activa' ? null : formData.fechaFin, // Asegurarse de que fechaFin es null si está activa diasEntrega: Array.from(selectedDays), }; @@ -156,43 +183,57 @@ const SuscripcionFormModal: React.FC = ({ open, onClo {isEditing ? 'Editar Suscripción' : 'Nueva Suscripción'} - - Publicación - - {localErrors.idPublicacion && {localErrors.idPublicacion}} - - Días de Entrega * - - - {dias.map(d => handleDayChange(d.value)} disabled={loading}/>} label={d.label} />)} - - - {localErrors.diasEntrega && {localErrors.diasEntrega}} - - - - - - - Estado - - - + + Publicación + + {localErrors.idPublicacion && {localErrors.idPublicacion}} + + Días de Entrega * + + + {dias.map(d => handleDayChange(d.value)} disabled={loading} />} label={d.label} />)} + + + {localErrors.diasEntrega && {localErrors.diasEntrega}} - {errorMessage && {errorMessage}} + + + + - - - - + + Estado + + + + + {errorMessage && {errorMessage}} + + + + + diff --git a/Frontend/src/models/dtos/Comunicaciones/LoteDeEnvioDto.ts b/Frontend/src/models/dtos/Comunicaciones/LoteDeEnvioDto.ts new file mode 100644 index 0000000..70736d1 --- /dev/null +++ b/Frontend/src/models/dtos/Comunicaciones/LoteDeEnvioDto.ts @@ -0,0 +1,23 @@ +import type { EmailLogDto } from "./EmailLogDto"; + +// Representa el resumen inmediato que se muestra tras el cierre +export interface LoteDeEnvioResumenDto { + idLoteDeEnvio: number; + periodo: string; + totalCorreos: number; + totalEnviados: number; + totalFallidos: number; + erroresDetallados: EmailLogDto[]; // Lista de errores inmediatos +} + +// Representa una fila en la tabla de historial +export interface LoteDeEnvioHistorialDto { + idLoteDeEnvio: number; + fechaInicio: string; + periodo: string; + estado: string; + totalCorreos: number; + totalEnviados: number; + totalFallidos: number; + nombreUsuarioDisparo: string; +} \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/CierreYProcesosPage.tsx b/Frontend/src/pages/Suscripciones/CierreYProcesosPage.tsx new file mode 100644 index 0000000..fe616d0 --- /dev/null +++ b/Frontend/src/pages/Suscripciones/CierreYProcesosPage.tsx @@ -0,0 +1,299 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Box, Typography, Paper, Alert, Button, CircularProgress, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Chip, Select, MenuItem, FormControl, InputLabel, Tabs, Tab } from '@mui/material'; +import facturacionService from '../../services/Suscripciones/facturacionService'; +import type { LoteDeEnvioHistorialDto, LoteDeEnvioResumenDto } from '../../models/dtos/Comunicaciones/LoteDeEnvioDto'; +import ResultadoEnvioModal from '../../components/Modals/Suscripciones/ResultadoEnvioModal'; +import { usePermissions } from '../../hooks/usePermissions'; +import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto'; + +const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i); +const meses = [ + { value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' }, + { value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' }, + { value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' }, + { value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' } +]; + +// --- Sub-componente para renderizar la Pestaña de Historial --- +const TabHistorial: React.FC<{ + puedeConsultar: boolean; + onVerDetalles: (lote: LoteDeEnvioHistorialDto) => void; + formatDate: (date: string) => string; +}> = ({ puedeConsultar, onVerDetalles, formatDate }) => { + const [historial, setHistorial] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filtroAnio, setFiltroAnio] = useState(new Date().getFullYear()); + const [filtroMes, setFiltroMes] = useState(new Date().getMonth() + 1); + + const cargarHistorial = useCallback(async () => { + if (!puedeConsultar) { setLoading(false); return; } + setLoading(true); setError(null); + try { + const data = await facturacionService.getHistorialLotesEnvio(filtroAnio, filtroMes); + setHistorial(data); + } catch (error) { + setError("No se pudo cargar el historial de cierres."); + } finally { + setLoading(false); + } + }, [puedeConsultar, filtroAnio, filtroMes]); + + useEffect(() => { + cargarHistorial(); + }, [cargarHistorial]); + + return ( + + + Filtrar Historial + + Mes + Año + + + + {error && {error}} + + + + + + Período + Fecha Proceso + Usuario + Total Correos + Enviados + Fallidos + Acciones + + + + {loading ? ( + + ) : historial.length === 0 ? ( + No hay registros para el período seleccionado. + ) : ( + historial.map(lote => ( + + {lote.periodo} + {formatDate(lote.fechaInicio)} + {lote.nombreUsuarioDisparo} + {lote.totalCorreos} + + 0 ? 'error' : 'default'} size="small" variant="outlined" /> + + + + + )) + )} + +
+
+
+ ); +}; + +const CierreYProcesosPage: React.FC = () => { + const [activeTab, setActiveTab] = useState(0); + const [selectedAnio, setSelectedAnio] = useState(new Date().getFullYear()); + const [selectedMes, setSelectedMes] = useState(new Date().getMonth() + 1); + const [loadingCierre, setLoadingCierre] = useState(false); + const [apiMessage, setApiMessage] = useState(null); + const [apiError, setApiError] = useState(null); + const [loadingArchivoDebito, setLoadingArchivoDebito] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [loadingRespuesta, setLoadingRespuesta] = useState(false); + const [respuestaProceso, setRespuestaProceso] = useState(null); + const [ultimoResultadoEnvio, setUltimoResultadoEnvio] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [selectedLoteId, setSelectedLoteId] = useState(null); + const [selectedLotePeriodo, setSelectedLotePeriodo] = useState(""); + + const { tienePermiso, isSuperAdmin } = usePermissions(); + const puedeGestionarCierre = isSuperAdmin || tienePermiso("SU006"); + const puedeGestionarDebitos = isSuperAdmin || tienePermiso("SU007"); + + const handleGenerarCierre = async () => { + if (!window.confirm(`¿Está seguro de que desea generar el cierre y la facturación para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Esta acción no se puede deshacer y enviará avisos por email.`)) return; + setLoadingCierre(true); + setApiMessage(null); + setApiError(null); + setUltimoResultadoEnvio(null); + try { + const { message, resultadoEnvio } = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes); + setApiMessage(message); + if (resultadoEnvio) { + setUltimoResultadoEnvio(resultadoEnvio); + } + } catch (err: any) { + setApiError(err.response?.data?.message || 'Error al generar el cierre del período.'); + } finally { + setLoadingCierre(false); + } + }; + + const handleGenerarArchivoDebito = async () => { + setLoadingArchivoDebito(true); + setApiError(null); + try { + const { fileContent, fileName } = await facturacionService.generarArchivoDebito(selectedAnio, selectedMes); + const url = window.URL.createObjectURL(new Blob([fileContent])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (err: any) { + setApiError(err.response?.data?.message || 'Error al generar el archivo de débito.'); + } finally { + setLoadingArchivoDebito(false); + } + }; + + const handleProcesarRespuesta = async () => { + if (!selectedFile) { + setApiError("Por favor, seleccione un archivo de respuesta."); + return; + } + setLoadingRespuesta(true); + setApiError(null); + setRespuestaProceso(null); + try { + const data = await facturacionService.procesarArchivoRespuesta(selectedFile); + setRespuestaProceso(data); + setApiMessage("Archivo de respuesta procesado exitosamente."); + } catch (err: any) { + const message = err.response?.data?.message || 'Error al procesar el archivo.'; + setApiError(message); + if (err.response?.data) { + setRespuestaProceso(err.response.data); + } + } finally { + setLoadingRespuesta(false); + } + }; + + const handleOpenModal = (lote: LoteDeEnvioHistorialDto) => { + setSelectedLoteId(lote.idLoteDeEnvio); + setSelectedLotePeriodo(lote.periodo); + setModalOpen(true); + }; + + const formatDisplayDateTime = (dateString: string): string => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleString('es-AR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + }; + + if (!puedeGestionarCierre && !puedeGestionarDebitos) { + return No tiene permiso para acceder a esta sección.; + } + + return ( + + Cierre de Período y Procesos Mensuales + + + setActiveTab(newValue)}> + + + + + + {activeTab === 0 && ( + + {apiError && setApiError(null)}>{apiError}} + {apiMessage && setApiMessage(null)}>{apiMessage}} + + {puedeGestionarCierre && ( + + 1. Generación de Cierre Mensual + + Este proceso calcula las deudas, aplica ajustes y promociones, y envía los avisos de vencimiento por email. + + + Mes + Año + + + {ultimoResultadoEnvio && ( + 0 ? "warning" : "success"} sx={{ mt: 2 }}> + + + + Resultado del envío de correos para {ultimoResultadoEnvio.periodo} + + + {ultimoResultadoEnvio.totalEnviados} enviados exitosamente, {ultimoResultadoEnvio.totalFallidos} fallidos. + + + + + + )} + + )} + + {puedeGestionarDebitos && ( + <> + + 2. Generación de Archivo para Banco + + Genere el archivo de texto para procesar los débitos automáticos del período seleccionado. + + + + + 3. Procesar Respuesta del Banco + + Suba el archivo de respuesta del banco para actualizar automáticamente el estado de los pagos. + + + + {selectedFile && {selectedFile.name}} + + + {respuestaProceso && ( + 0 ? "warning" : "info"} sx={{ mt: 2 }}> + {respuestaProceso.mensajeResumen} + {respuestaProceso.errores.length > 0 && ( +
    {respuestaProceso.errores.map((e: string, i: number) =>
  • {e}
  • )}
+ )} +
+ )} +
+ + )} +
+ )} + + {activeTab === 1 && ( + + )} + + setModalOpen(false)} + loteId={selectedLoteId} + periodo={selectedLotePeriodo} + /> +
+ ); +}; + +export default CierreYProcesosPage; \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/FacturacionPage.tsx b/Frontend/src/pages/Suscripciones/FacturacionPage.tsx deleted file mode 100644 index f31fb66..0000000 --- a/Frontend/src/pages/Suscripciones/FacturacionPage.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import React, { useState } from 'react'; -import { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel } from '@mui/material'; -import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; -import DownloadIcon from '@mui/icons-material/Download'; -import UploadFileIcon from '@mui/icons-material/UploadFile'; -import { styled } from '@mui/material/styles'; -import facturacionService from '../../services/Suscripciones/facturacionService'; -import { usePermissions } from '../../hooks/usePermissions'; -import axios from 'axios'; - -const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i); -const meses = [ - { value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' }, - { value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' }, - { value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' }, - { value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' } -]; - -const VisuallyHiddenInput = styled('input')({ - clip: 'rect(0 0 0 0)', clipPath: 'inset(50%)', height: 1, overflow: 'hidden', - position: 'absolute', bottom: 0, left: 0, whiteSpace: 'nowrap', width: 1, -}); - -const FacturacionPage: React.FC = () => { - const [selectedAnio, setSelectedAnio] = useState(new Date().getFullYear()); - const [selectedMes, setSelectedMes] = useState(new Date().getMonth() + 1); - const [loading, setLoading] = useState(false); - const [loadingArchivo, setLoadingArchivo] = useState(false); - const [loadingProceso, setLoadingProceso] = useState(false); - const [apiMessage, setApiMessage] = useState(null); - const [apiError, setApiError] = useState(null); - const [archivoSeleccionado, setArchivoSeleccionado] = useState(null); - - const { tienePermiso, isSuperAdmin } = usePermissions(); - const puedeGenerarFacturacion = isSuperAdmin || tienePermiso("SU006"); - const puedeGenerarArchivo = isSuperAdmin || tienePermiso("SU007"); - - const handleGenerarFacturacion = async () => { - if (!window.confirm(`¿Está seguro de generar el cierre para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Se aplicarán los ajustes pendientes del mes anterior y se generarán los nuevos importes a cobrar.`)) { - return; - } - setLoading(true); - setApiMessage(null); - setApiError(null); - try { - const response = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes); - setApiMessage(`${response.message}. Se generaron ${response.facturasGeneradas} facturas.`); - } catch (err: any) { - const message = axios.isAxiosError(err) && err.response?.data?.message - ? err.response.data.message - : 'Ocurrió un error al generar la facturación.'; - setApiError(message); - } finally { - setLoading(false); - } - }; - - const handleGenerarArchivo = async () => { - if (!window.confirm(`Se generará el archivo de débito para las facturas del período ${meses.find(m => m.value === selectedMes)?.label}/${selectedAnio} que estén en estado 'Pendiente de Cobro'. ¿Continuar?`)) { - return; - } - setLoadingArchivo(true); - setApiMessage(null); - setApiError(null); - try { - const { fileContent, fileName } = await facturacionService.generarArchivoDebito(selectedAnio, selectedMes); - const url = window.URL.createObjectURL(new Blob([fileContent])); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', fileName); - document.body.appendChild(link); - link.click(); - link.parentNode?.removeChild(link); - window.URL.revokeObjectURL(url); - setApiMessage(`Archivo "${fileName}" generado y descargado exitosamente.`); - } catch (err: any) { - let message = 'Ocurrió un error al generar el archivo.'; - if (axios.isAxiosError(err) && err.response) { - const errorText = await err.response.data.text(); - try { - const errorJson = JSON.parse(errorText); - message = errorJson.message || message; - } catch { message = errorText || message; } - } - setApiError(message); - } finally { - setLoadingArchivo(false); - } - }; - - const handleFileChange = (event: React.ChangeEvent) => { - if (event.target.files && event.target.files.length > 0) { - setArchivoSeleccionado(event.target.files[0]); - setApiMessage(null); - setApiError(null); - } - }; - - const handleProcesarArchivo = async () => { - if (!archivoSeleccionado) { - setApiError("Por favor, seleccione un archivo de respuesta para procesar."); - return; - } - setLoadingProceso(true); - setApiMessage(null); - setApiError(null); - try { - const response = await facturacionService.procesarArchivoRespuesta(archivoSeleccionado); - setApiMessage(response.mensajeResumen); - if (response.errores?.length > 0) { - setApiError(`Se encontraron los siguientes problemas durante el proceso:\n${response.errores.join('\n')}`); - } - } catch (err: any) { - const message = axios.isAxiosError(err) && err.response?.data?.mensajeResumen - ? err.response.data.mensajeResumen - : 'Ocurrió un error crítico al procesar el archivo.'; - setApiError(message); - } finally { - setLoadingProceso(false); - setArchivoSeleccionado(null); - } - }; - - if (!puedeGenerarFacturacion) { - return No tiene permiso para acceder a esta sección.; - } - - if (!puedeGenerarFacturacion) { - return No tiene permiso para acceder a esta sección.; - } - - return ( - - Procesos Mensuales de Suscripciones - - - 1. Generación de Cierre Mensual - - Este proceso calcula los importes a cobrar y envía automáticamente una notificación de "Aviso de Vencimiento" a cada suscriptor. - - - - Mes - - - - Año - - - - - - - - 2. Generación de Archivo para Banco - Crea el archivo de texto para enviar a "Pago Directo Galicia" con todas las facturas del período que estén listas para el cobro. - - - - - 3. Procesar Respuesta del Banco - - Suba aquí el archivo de respuesta de Galicia para actualizar automáticamente el estado de las facturas a "Pagada" o "Rechazada". - - - - {archivoSeleccionado && {archivoSeleccionado.name}} - - {archivoSeleccionado && ( - - )} - - - {apiError && {apiError}} - {apiMessage && {apiMessage}} - - ); -}; - -export default FacturacionPage; \ No newline at end of file diff --git a/Frontend/src/routes/AppRoutes.tsx b/Frontend/src/routes/AppRoutes.tsx index 9921133..34c8dd9 100644 --- a/Frontend/src/routes/AppRoutes.tsx +++ b/Frontend/src/routes/AppRoutes.tsx @@ -83,7 +83,7 @@ import SuscripcionesIndexPage from '../pages/Suscripciones/SuscripcionesIndexPag import GestionarSuscriptoresPage from '../pages/Suscripciones/GestionarSuscriptoresPage'; import GestionarPromocionesPage from '../pages/Suscripciones/GestionarPromocionesPage'; import ConsultaFacturasPage from '../pages/Suscripciones/ConsultaFacturasPage'; -import FacturacionPage from '../pages/Suscripciones/FacturacionPage'; +import CierreYProcesosPage from '../pages/Suscripciones/CierreYProcesosPage'; import GestionarSuscripcionesDeClientePage from '../pages/Suscripciones/GestionarSuscripcionesDeClientePage'; import CuentaCorrienteSuscriptorPage from '../pages/Suscripciones/CuentaCorrienteSuscriptorPage'; @@ -206,7 +206,7 @@ const AppRoutes = () => { } /> } /> } /> - } /> + } /> } /> diff --git a/Frontend/src/services/Suscripciones/facturacionService.ts b/Frontend/src/services/Suscripciones/facturacionService.ts index 57940c2..97e0d76 100644 --- a/Frontend/src/services/Suscripciones/facturacionService.ts +++ b/Frontend/src/services/Suscripciones/facturacionService.ts @@ -1,9 +1,9 @@ import apiClient from '../apiClient'; -import type { GenerarFacturacionResponseDto } from '../../models/dtos/Suscripciones/GenerarFacturacionResponseDto'; import type { PagoDto } from '../../models/dtos/Suscripciones/PagoDto'; import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto'; import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto'; import type { ResumenCuentaSuscriptorDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto'; +import type { LoteDeEnvioHistorialDto, LoteDeEnvioResumenDto } from '../../models/dtos/Comunicaciones/LoteDeEnvioDto'; import type { EmailLogDto } from '../../models/dtos/Comunicaciones/EmailLogDto'; const API_URL = '/facturacion'; @@ -32,8 +32,8 @@ const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreS return response.data; }; -const generarFacturacionMensual = async (anio: number, mes: number): Promise => { - const response = await apiClient.post(`${API_URL}/${anio}/${mes}`); +const generarFacturacionMensual = async (anio: number, mes: number): Promise<{ message: string, resultadoEnvio: LoteDeEnvioResumenDto }> => { + const response = await apiClient.post<{ message: string, resultadoEnvio: LoteDeEnvioResumenDto }>(`${API_URL}/${anio}/${mes}`); return response.data; }; @@ -77,6 +77,23 @@ const getHistorialEnvios = async (idFactura: number): Promise => return response.data; }; +const getHistorialLotesEnvio = async (anio?: number, mes?: number): Promise => { + const params = new URLSearchParams(); + if (anio) params.append('anio', String(anio)); + if (mes) params.append('mes', String(mes)); + + const queryString = params.toString(); + const url = `${API_URL}/historial-lotes-envio${queryString ? `?${queryString}` : ''}`; + + const response = await apiClient.get(url); + return response.data; +}; + +const getDetallesLoteEnvio = async (idLote: number): Promise => { + const response = await apiClient.get(`/lotes-envio/${idLote}/detalles`); + return response.data; +}; + export default { procesarArchivoRespuesta, getResumenesDeCuentaPorPeriodo, @@ -87,4 +104,6 @@ export default { enviarAvisoCuentaPorEmail, enviarFacturaPdfPorEmail, getHistorialEnvios, + getHistorialLotesEnvio, + getDetallesLoteEnvio, }; \ No newline at end of file