Refactor: Mejora la lógica de facturación y la UI
Este commit introduce una refactorización significativa en el módulo de
suscripciones para alinear el sistema con reglas de negocio clave:
facturación consolidada por empresa, cobro a mes adelantado con
imputación de ajustes diferida, y una interfaz de usuario más clara.
Backend:
- **Facturación por Empresa:** Se modifica `FacturacionService` para
  agrupar las suscripciones por cliente y empresa, generando una
  factura consolidada para cada combinación. Esto asegura la correcta
  separación fiscal.
- **Imputación de Ajustes:** Se ajusta la lógica para que la facturación
  de un período (ej. Septiembre) aplique únicamente los ajustes
  pendientes cuya fecha corresponde al período anterior (Agosto).
- **Cierre Secuencial:** Se implementa una validación en
  `GenerarFacturacionMensual` que impide generar la facturación de un
  período si el anterior no ha sido cerrado, garantizando el orden
  cronológico.
- **Emails Consolidados:** El proceso de notificación automática al
  generar el cierre ahora envía un único email consolidado por
  suscriptor, detallando los cargos de todas sus facturas/empresas.
- **Envío de PDF Individual:** Se refactoriza el endpoint de envío manual
  para que opere sobre una `idFactura` individual y adjunte el PDF
  correspondiente si existe.
- **Repositorios Mejorados:** Se optimizan y añaden métodos en
  `FacturaRepository` y `AjusteRepository` para soportar los nuevos
  requisitos de filtrado y consulta de datos consolidados.
Frontend:
- **Separación de Vistas:** La página de "Facturación" se divide en dos:
  - `ProcesosPage`: Para acciones masivas (generar cierre, archivo de
    débito, procesar respuesta).
  - `ConsultaFacturasPage`: Una nueva página dedicada a buscar,
    filtrar y gestionar facturas individuales con una interfaz de doble
    acordeón (Suscriptor -> Empresa).
- **Filtros Avanzados:** La página `ConsultaFacturasPage` ahora incluye
  filtros por nombre de suscriptor, estado de pago y estado de
  facturación.
- **Filtros de Fecha por Defecto:** La página de "Cuenta Corriente"
  ahora filtra por el mes actual por defecto para mejorar el rendimiento
  y la usabilidad.
- **Validación de Fechas:** Se añade lógica en los filtros de fecha para
  impedir la selección de rangos inválidos.
- **Validación de Monto de Pago:** El modal de pago manual ahora impide
  registrar un monto superior al saldo pendiente de la factura.
			
			
This commit is contained in:
		| @@ -6,6 +6,8 @@ using GestionIntegral.Api.Models.Distribucion; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Comunicaciones; | ||||
| using System.Data; | ||||
| using System.Globalization; | ||||
| using System.Text; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
| @@ -13,40 +15,393 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|     { | ||||
|         private readonly ISuscripcionRepository _suscripcionRepository; | ||||
|         private readonly IFacturaRepository _facturaRepository; | ||||
|         private readonly IEmpresaRepository _empresaRepository; | ||||
|         private readonly IFacturaDetalleRepository _facturaDetalleRepository; | ||||
|         private readonly IPrecioRepository _precioRepository; | ||||
|         private readonly IPromocionRepository _promocionRepository; | ||||
|         private readonly IRecargoZonaRepository _recargoZonaRepository; // Para futura implementación | ||||
|         private readonly ISuscriptorRepository _suscriptorRepository; // Para obtener zona del suscriptor | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ISuscriptorRepository _suscriptorRepository; | ||||
|         private readonly IAjusteRepository _ajusteRepository; | ||||
|         private readonly IEmailService _emailService; | ||||
|         private readonly IPublicacionRepository _publicacionRepository; | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<FacturacionService> _logger; | ||||
|         private readonly string _facturasPdfPath; | ||||
|  | ||||
|         public FacturacionService( | ||||
|             ISuscripcionRepository suscripcionRepository, | ||||
|             IFacturaRepository facturaRepository, | ||||
|             IEmpresaRepository empresaRepository, | ||||
|             IFacturaDetalleRepository facturaDetalleRepository, | ||||
|             IPrecioRepository precioRepository, | ||||
|             IPromocionRepository promocionRepository, | ||||
|             IRecargoZonaRepository recargoZonaRepository, | ||||
|             ISuscriptorRepository suscriptorRepository, | ||||
|             DbConnectionFactory connectionFactory, | ||||
|             IAjusteRepository ajusteRepository, | ||||
|             IEmailService emailService, | ||||
|             ILogger<FacturacionService> logger) | ||||
|             IPublicacionRepository publicacionRepository, | ||||
|             DbConnectionFactory connectionFactory, | ||||
|             ILogger<FacturacionService> logger, | ||||
|             IConfiguration configuration) | ||||
|         { | ||||
|             _suscripcionRepository = suscripcionRepository; | ||||
|             _facturaRepository = facturaRepository; | ||||
|             _empresaRepository = empresaRepository; | ||||
|             _facturaDetalleRepository = facturaDetalleRepository; | ||||
|             _precioRepository = precioRepository; | ||||
|             _promocionRepository = promocionRepository; | ||||
|             _recargoZonaRepository = recargoZonaRepository; | ||||
|             _suscriptorRepository = suscriptorRepository; | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _ajusteRepository = ajusteRepository; | ||||
|             _emailService = emailService; | ||||
|             _publicacionRepository = publicacionRepository; | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|             _facturasPdfPath = configuration.GetValue<string>("AppSettings:FacturasPdfPath") ?? "C:\\FacturasPDF"; | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> 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); | ||||
|  | ||||
|             var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync(); | ||||
|             if (ultimoPeriodoFacturadoStr != null) | ||||
|             { | ||||
|                 var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture); | ||||
|                 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); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             var facturasCreadas = new List<Factura>(); | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 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); | ||||
|                 } | ||||
|  | ||||
|                 // 1. Enriquecer las suscripciones con el IdEmpresa de su publicación | ||||
|                 var suscripcionesConEmpresa = new List<(Suscripcion Suscripcion, int IdEmpresa)>(); | ||||
|                 foreach (var s in suscripcionesActivas) | ||||
|                 { | ||||
|                     var pub = await _publicacionRepository.GetByIdSimpleAsync(s.IdPublicacion); | ||||
|                     if (pub != null) | ||||
|                     { | ||||
|                         suscripcionesConEmpresa.Add((s, pub.IdEmpresa)); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 // 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; | ||||
|  | ||||
|                     // La verificación de existencia ahora debe ser más específica, pero por ahora la omitimos | ||||
|                     // para no añadir otro método al repositorio. Asumimos que no se corre dos veces. | ||||
|  | ||||
|                     decimal importeBrutoTotal = 0; | ||||
|                     decimal descuentoPromocionesTotal = 0; | ||||
|                     var detallesParaFactura = new List<FacturaDetalle>(); | ||||
|  | ||||
|                     // 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 | ||||
|                         { | ||||
|                             IdSuscripcion = suscripcion.IdSuscripcion, | ||||
|                             Descripcion = $"Corresponde a {publicacion?.Nombre ?? "N/A"}", | ||||
|                             ImporteBruto = importeBrutoSusc, | ||||
|                             DescuentoAplicado = descuentoSusc, | ||||
|                             ImporteNeto = importeBrutoSusc - descuentoSusc | ||||
|                         }); | ||||
|                     } | ||||
|  | ||||
|                     // 4. Aplicar ajustes. Se aplican a la PRIMERA factura que se genere para el cliente. | ||||
|                     var ultimoDiaDelMes = periodoActual.AddMonths(1).AddDays(-1); | ||||
|                     var ajustesPendientes = await _ajusteRepository.GetAjustesPendientesHastaFechaAsync(idSuscriptor, ultimoDiaDelMes, transaction); | ||||
|                     decimal totalAjustes = 0; | ||||
|  | ||||
|                     // 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, | ||||
|                         Periodo = periodoActualStr, | ||||
|                         FechaEmision = DateTime.Now.Date, | ||||
|                         FechaVencimiento = new DateTime(anio, mes, 10), | ||||
|                         ImporteBruto = importeBrutoTotal, | ||||
|                         DescuentoAplicado = descuentoPromocionesTotal, | ||||
|                         ImporteFinal = importeFinal, | ||||
|                         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 (esPrimerGrupoParaCliente && 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) | ||||
|                     { | ||||
|                         try | ||||
|                         { | ||||
|                             var (envioExitoso, _) = await EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor); | ||||
|                             if (envioExitoso) emailsEnviados++; | ||||
|                         } | ||||
|                         catch (Exception exEmail) | ||||
|                         { | ||||
|                             _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); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _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); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) | ||||
|         { | ||||
|             var periodo = $"{anio}-{mes:D2}"; | ||||
|             _logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodo, idUsuario); | ||||
|             var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion); | ||||
|             var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); // Necesitaremos este nuevo método en el repo | ||||
|             var empresas = await _empresaRepository.GetAllAsync(null, null); | ||||
|  | ||||
|             var resumenes = facturasData | ||||
|                 .GroupBy(data => data.Factura.IdSuscriptor) | ||||
|                 .Select(grupo => | ||||
|                 { | ||||
|                     var primerItem = grupo.First(); | ||||
|                     var facturasConsolidadas = grupo.Select(itemFactura => | ||||
|                     { | ||||
|                         var empresa = empresas.FirstOrDefault(e => e.IdEmpresa == itemFactura.IdEmpresa); | ||||
|                         return new FacturaConsolidadaDto | ||||
|                         { | ||||
|                             IdFactura = itemFactura.Factura.IdFactura, | ||||
|                             NombreEmpresa = empresa?.Nombre ?? "N/A", | ||||
|                             ImporteFinal = itemFactura.Factura.ImporteFinal, | ||||
|                             EstadoPago = itemFactura.Factura.EstadoPago, | ||||
|                             EstadoFacturacion = itemFactura.Factura.EstadoFacturacion, | ||||
|                             NumeroFactura = itemFactura.Factura.NumeroFactura, | ||||
|                             Detalles = detallesData | ||||
|                                 .Where(d => d.IdFactura == itemFactura.Factura.IdFactura) | ||||
|                                 .Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto }) | ||||
|                                 .ToList() | ||||
|                         }; | ||||
|                     }).ToList(); | ||||
|  | ||||
|                     return new ResumenCuentaSuscriptorDto | ||||
|                     { | ||||
|                         IdSuscriptor = primerItem.Factura.IdSuscriptor, | ||||
|                         NombreSuscriptor = primerItem.NombreSuscriptor, | ||||
|                         Facturas = facturasConsolidadas, | ||||
|                         ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal), | ||||
|                         SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.EstadoPago == "Pagada" ? 0 : f.ImporteFinal) | ||||
|                     }; | ||||
|                 }); | ||||
|  | ||||
|             return resumenes.ToList(); | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Exito, string? Error)> EnviarFacturaPdfPorEmail(int idFactura) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 var factura = await _facturaRepository.GetByIdAsync(idFactura); | ||||
|                 if (factura == null) return (false, "Factura no encontrada."); | ||||
|                 if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura no tiene un número oficial asignado para generar el PDF."); | ||||
|                 if (factura.EstadoPago == "Anulada") return (false, "No se puede enviar email de una factura anulada."); | ||||
|  | ||||
|                 var suscriptor = await _suscriptorRepository.GetByIdAsync(factura.IdSuscriptor); | ||||
|                 if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email."); | ||||
|  | ||||
|                 var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura); | ||||
|                 var primeraSuscripcionId = detalles.FirstOrDefault()?.IdSuscripcion ?? 0; | ||||
|                 var publicacion = await _publicacionRepository.GetByIdSimpleAsync(primeraSuscripcionId); | ||||
|                 var empresa = await _empresaRepository.GetByIdAsync(publicacion?.IdEmpresa ?? 0); | ||||
|  | ||||
|                 // --- LÓGICA DE BÚSQUEDA Y ADJUNTO DE PDF --- | ||||
|                 byte[]? pdfAttachment = null; | ||||
|                 string? pdfFileName = null; | ||||
|                 var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf"); | ||||
|  | ||||
|                 if (File.Exists(rutaCompleta)) | ||||
|                 { | ||||
|                     pdfAttachment = await File.ReadAllBytesAsync(rutaCompleta); | ||||
|                     pdfFileName = $"Factura_{empresa?.Nombre?.Replace(" ", "")}_{factura.NumeroFactura}.pdf"; | ||||
|                     _logger.LogInformation("Adjuntando PDF encontrado en: {Ruta}", rutaCompleta); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     _logger.LogWarning("Se intentó enviar la factura {NumeroFactura} pero no se encontró el PDF en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta); | ||||
|                     return (false, "No se encontró el archivo PDF correspondiente en el servidor. Verifique que el archivo exista y el nombre coincida con el número de factura."); | ||||
|                 } | ||||
|  | ||||
|                 string asunto = $"Tu Factura Oficial - Diario El Día - Período {factura.Periodo}"; | ||||
|                 string cuerpoHtml = $"<div style='font-family: Arial, sans-serif;'><h2>Hola {suscriptor.NombreCompleto},</h2><p>Adjuntamos tu factura oficial número <strong>{factura.NumeroFactura}</strong> correspondiente al período <strong>{factura.Periodo}</strong>.</p><p>Gracias por ser parte de nuestra comunidad de lectores.</p><p><em>Diario El Día</em></p></div>"; | ||||
|  | ||||
|                 await _emailService.EnviarEmailAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, pdfAttachment, pdfFileName); | ||||
|                 _logger.LogInformation("Email con factura PDF ID {IdFactura} enviado para Suscriptor ID {IdSuscriptor}", idFactura, suscriptor.IdSuscriptor); | ||||
|                 return (true, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Falló el envío de email con PDF para la factura ID {IdFactura}", idFactura); | ||||
|                 return (false, "Ocurrió un error al intentar enviar el email con la factura."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor) | ||||
|         { | ||||
|             var periodo = $"{anio}-{mes:D2}"; | ||||
|             try | ||||
|             { | ||||
|                 var facturas = await _facturaRepository.GetListBySuscriptorYPeriodoAsync(idSuscriptor, periodo); | ||||
|                 if (!facturas.Any()) return (false, "No se encontraron facturas para este suscriptor en el período."); | ||||
|  | ||||
|                 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 resumenHtml = new StringBuilder(); | ||||
|                 var adjuntos = new List<(byte[] content, string name)>(); | ||||
|  | ||||
|                 foreach (var factura in facturas.Where(f => f.EstadoPago != "Anulada")) | ||||
|                 { | ||||
|                     var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura); | ||||
|                     if (!detalles.Any()) continue; | ||||
|  | ||||
|                     var primeraSuscripcionId = detalles.First().IdSuscripcion; | ||||
|                     var publicacion = await _publicacionRepository.GetByIdSimpleAsync(primeraSuscripcionId); | ||||
|                     var empresa = await _empresaRepository.GetByIdAsync(publicacion?.IdEmpresa ?? 0); | ||||
|  | ||||
|                     resumenHtml.Append($"<h4 style='margin-top: 20px; margin-bottom: 10px; color: #34515e;'>Resumen de Suscripción</h4>"); | ||||
|                     resumenHtml.Append("<table style='width: 100%; border-collapse: collapse; font-size: 0.9em;'>"); | ||||
|                     foreach (var detalle in detalles) | ||||
|                     { | ||||
|                         resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee;'>{detalle.Descripcion}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right;'>${detalle.ImporteNeto:N2}</td></tr>"); | ||||
|                     } | ||||
|                     resumenHtml.Append($"<tr style='font-weight: bold;'><td style='padding: 5px;'>Subtotal</td><td style='padding: 5px; text-align: right;'>${factura.ImporteFinal:N2}</td></tr>"); | ||||
|                     resumenHtml.Append("</table>"); | ||||
|  | ||||
|                     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_{empresa?.Nombre?.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); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 var totalGeneral = facturas.Where(f => f.EstadoPago != "Anulada").Sum(f => f.ImporteFinal); | ||||
|                 string asunto = $"Resumen de Cuenta - Diario El Día - Período {periodo}"; | ||||
|                 string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral); | ||||
|  | ||||
|                 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."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private string ConstruirCuerpoEmailConsolidado(Suscriptor suscriptor, string periodo, string resumenHtml, decimal totalGeneral) | ||||
|         { | ||||
|             return $@" | ||||
|             <div style='font-family: Arial, sans-serif; max-width: 600px; margin: auto; border: 1px solid #ddd; padding: 20px;'> | ||||
|                 <h3 style='color: #333;'>Hola {suscriptor.NombreCompleto},</h2> | ||||
|                 <p>Le enviamos el resumen de su cuenta para el período <strong>{periodo}</strong>.</p> | ||||
|                 {resumenHtml} | ||||
|                 <hr style='border: none; border-top: 1px solid #eee; margin: 20px 0;'/> | ||||
|                 <table style='width: 100%;'> | ||||
|                     <tr> | ||||
|                         <td style='font-size: 1.2em; font-weight: bold;'>TOTAL:</td> | ||||
|                         <td style='font-size: 1.4em; font-weight: bold; text-align: right; color: #34515e;'>${totalGeneral:N2}</td> | ||||
|                     </tr> | ||||
|                 </table> | ||||
|                 <p style='margin-top: 25px;'>Si su pago es por débito automático, los importes se debitarán de su cuenta. Si utiliza otro medio de pago, por favor, regularice su situación.</p> | ||||
|                 <p>Gracias por ser parte de nuestra comunidad de lectores.</p> | ||||
|                 <p style='font-size: 0.9em; color: #777;'><em>Diario El Día</em></p> | ||||
|             </div>"; | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(numeroFactura)) | ||||
|             { | ||||
|                 return (false, "El número de factura no puede estar vacío."); | ||||
|             } | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
| @@ -54,77 +409,31 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodo, transaction); | ||||
|                 if (!suscripcionesActivas.Any()) | ||||
|                 var factura = await _facturaRepository.GetByIdAsync(idFactura); | ||||
|                 if (factura == null) | ||||
|                 { | ||||
|                     return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0); | ||||
|                     return (false, "La factura especificada no existe."); | ||||
|                 } | ||||
|                 if (factura.EstadoPago == "Anulada") | ||||
|                 { | ||||
|                     return (false, "No se puede modificar una factura anulada."); | ||||
|                 } | ||||
|  | ||||
|                 int facturasGeneradas = 0; | ||||
|                 foreach (var suscripcion in suscripcionesActivas) | ||||
|                 var actualizado = await _facturaRepository.UpdateNumeroFacturaAsync(idFactura, numeroFactura, transaction); | ||||
|                 if (!actualizado) | ||||
|                 { | ||||
|                     var facturaExistente = await _facturaRepository.GetBySuscripcionYPeriodoAsync(suscripcion.IdSuscripcion, periodo, transaction); | ||||
|                     if (facturaExistente != null) | ||||
|                     { | ||||
|                         _logger.LogWarning("Ya existe una factura (ID: {IdFactura}) para la suscripción ID {IdSuscripcion} en el período {Periodo}. Se omite.", facturaExistente.IdFactura, suscripcion.IdSuscripcion, periodo); | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     // --- LÓGICA DE PROMOCIONES --- | ||||
|                     var primerDiaMes = new DateTime(anio, mes, 1); | ||||
|                     var promocionesAplicables = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, primerDiaMes, transaction); | ||||
|  | ||||
|                     decimal importeBruto = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction); | ||||
|                     decimal descuentoTotal = 0; | ||||
|  | ||||
|                     // Aplicar promociones de descuento | ||||
|                     foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "Porcentaje")) | ||||
|                     { | ||||
|                         descuentoTotal += (importeBruto * promo.Valor) / 100; | ||||
|                     } | ||||
|                     foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "MontoFijo")) | ||||
|                     { | ||||
|                         descuentoTotal += promo.Valor; | ||||
|                     } | ||||
|                     // La bonificación de días se aplicaría idealmente dentro de CalcularImporteParaSuscripcion, | ||||
|                     // pero por simplicidad, aquí solo manejamos descuentos sobre el total. | ||||
|  | ||||
|                     if (importeBruto <= 0) | ||||
|                     { | ||||
|                         _logger.LogInformation("Suscripción ID {IdSuscripcion} no tiene importe a facturar para el período {Periodo}. Se omite.", suscripcion.IdSuscripcion, periodo); | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     var importeFinal = importeBruto - descuentoTotal; | ||||
|                     if (importeFinal < 0) importeFinal = 0; // El importe no puede ser negativo | ||||
|  | ||||
|                     var nuevaFactura = new Factura | ||||
|                     { | ||||
|                         IdSuscripcion = suscripcion.IdSuscripcion, | ||||
|                         Periodo = periodo, | ||||
|                         FechaEmision = DateTime.Now.Date, | ||||
|                         FechaVencimiento = new DateTime(anio, mes, 10).AddMonths(1), | ||||
|                         ImporteBruto = importeBruto, | ||||
|                         DescuentoAplicado = descuentoTotal, | ||||
|                         ImporteFinal = importeFinal, | ||||
|                         Estado = "Pendiente de Facturar" | ||||
|                     }; | ||||
|  | ||||
|                     var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction); | ||||
|                     if (facturaCreada == null) throw new DataException($"No se pudo crear el registro de factura para la suscripción ID {suscripcion.IdSuscripcion}"); | ||||
|  | ||||
|                     facturasGeneradas++; | ||||
|                     throw new DataException("La actualización del número de factura falló en el repositorio."); | ||||
|                 } | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 _logger.LogInformation("Finalizada la generación de facturación para {Periodo}. Total generadas: {FacturasGeneradas}", periodo, facturasGeneradas); | ||||
|                 return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", facturasGeneradas); | ||||
|                 _logger.LogInformation("Número de factura para Factura ID {IdFactura} actualizado a {NumeroFactura} por Usuario ID {IdUsuario}", idFactura, numeroFactura, idUsuario); | ||||
|                 return (true, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodo); | ||||
|                 return (false, "Error interno del servidor al generar la facturación.", 0); | ||||
|                 _logger.LogError(ex, "Error al actualizar número de factura para Factura ID {IdFactura}", idFactura); | ||||
|                 return (false, "Error interno al actualizar el número de factura."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
| @@ -133,25 +442,34 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|             decimal importeTotal = 0; | ||||
|             var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); | ||||
|             var fechaActual = new DateTime(anio, mes, 1); | ||||
|             var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, fechaActual, transaction); | ||||
|             var promocionesDeBonificacion = promociones.Where(p => p.TipoEfecto == "BonificarEntregaDia").ToList(); | ||||
|  | ||||
|             while (fechaActual.Month == mes) | ||||
|             { | ||||
|                 // La suscripción debe estar activa en este día | ||||
|                 if (fechaActual.Date >= suscripcion.FechaInicio.Date && | ||||
|                     (suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date)) | ||||
|                 if (fechaActual.Date >= suscripcion.FechaInicio.Date && (suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date)) | ||||
|                 { | ||||
|                     var diaSemanaChar = GetCharDiaSemana(fechaActual.DayOfWeek); | ||||
|                     if (diasDeEntrega.Contains(diaSemanaChar)) | ||||
|                     { | ||||
|                         decimal precioDelDia = 0; | ||||
|                         var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction); | ||||
|                         if (precioActivo != null) | ||||
|                         { | ||||
|                             importeTotal += GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek); | ||||
|                             precioDelDia = GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek); | ||||
|                         } | ||||
|                         else | ||||
|                         { | ||||
|                             _logger.LogWarning("No se encontró precio para la publicación ID {IdPublicacion} en la fecha {Fecha}", suscripcion.IdPublicacion, fechaActual.Date); | ||||
|                         } | ||||
|  | ||||
|                         bool diaBonificado = promocionesDeBonificacion.Any(promo => EvaluarCondicionPromocion(promo, fechaActual)); | ||||
|                         if (diaBonificado) | ||||
|                         { | ||||
|                             precioDelDia = 0; | ||||
|                             _logger.LogInformation("Día {Fecha} bonificado para suscripción {IdSuscripcion} por promoción.", fechaActual.ToShortDateString(), suscripcion.IdSuscripcion); | ||||
|                         } | ||||
|                         importeTotal += precioDelDia; | ||||
|                     } | ||||
|                 } | ||||
|                 fechaActual = fechaActual.AddDays(1); | ||||
| @@ -159,72 +477,30 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|             return importeTotal; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes) | ||||
|         private bool EvaluarCondicionPromocion(Promocion promocion, DateTime fecha) | ||||
|         { | ||||
|             var periodo = $"{anio}-{mes:D2}"; | ||||
|             var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo); | ||||
|  | ||||
|             return facturasData.Select(data => new FacturaDto | ||||
|             switch (promocion.TipoCondicion) | ||||
|             { | ||||
|                 IdFactura = data.Factura.IdFactura, | ||||
|                 IdSuscripcion = data.Factura.IdSuscripcion, | ||||
|                 Periodo = data.Factura.Periodo, | ||||
|                 FechaEmision = data.Factura.FechaEmision.ToString("yyyy-MM-dd"), | ||||
|                 FechaVencimiento = data.Factura.FechaVencimiento.ToString("yyyy-MM-dd"), | ||||
|                 ImporteFinal = data.Factura.ImporteFinal, | ||||
|                 Estado = data.Factura.Estado, | ||||
|                 NumeroFactura = data.Factura.NumeroFactura, | ||||
|                 NombreSuscriptor = data.NombreSuscriptor, | ||||
|                 NombrePublicacion = data.NombrePublicacion | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura) | ||||
|         { | ||||
|             var factura = await _facturaRepository.GetByIdAsync(idFactura); | ||||
|             if (factura == null) return (false, "Factura no encontrada."); | ||||
|             if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura aún no tiene un número asignado por ARCA."); | ||||
|  | ||||
|             var suscripcion = await _suscripcionRepository.GetByIdAsync(factura.IdSuscripcion); | ||||
|             if (suscripcion == null) return (false, "Suscripción asociada no encontrada."); | ||||
|  | ||||
|             var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor); | ||||
|             if (suscriptor == null) return (false, "Suscriptor asociado no encontrado."); | ||||
|             if (string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no tiene una dirección de email configurada."); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var asunto = $"Tu factura del Diario El Día - Período {factura.Periodo}"; | ||||
|                 var cuerpo = $@" | ||||
|             <h1>Hola {suscriptor.NombreCompleto},</h1> | ||||
|             <p>Te adjuntamos los detalles de tu factura para el período {factura.Periodo}.</p> | ||||
|             <ul> | ||||
|                 <li><strong>Número de Factura:</strong> {factura.NumeroFactura}</li> | ||||
|                 <li><strong>Importe Total:</strong> ${factura.ImporteFinal:N2}</li> | ||||
|                 <li><strong>Fecha de Vencimiento:</strong> {factura.FechaVencimiento:dd/MM/yyyy}</li> | ||||
|             </ul> | ||||
|             <p>Gracias por ser parte de nuestra comunidad de lectores.</p> | ||||
|             <p><em>Diario El Día</em></p>"; | ||||
|  | ||||
|                 await _emailService.EnviarEmailAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpo); | ||||
|                 return (true, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Falló el envío de email para la factura ID {IdFactura}", idFactura); | ||||
|                 return (false, "Ocurrió un error al intentar enviar el email."); | ||||
|                 case "Siempre": return true; | ||||
|                 case "DiaDeSemana": | ||||
|                     int diaSemanaActual = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek; | ||||
|                     return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActual; | ||||
|                 case "PrimerDiaSemanaDelMes": | ||||
|                     int diaSemanaActualMes = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek; | ||||
|                     return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActualMes && fecha.Day <= 7; | ||||
|                 default: return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private string GetCharDiaSemana(DayOfWeek dia) => dia switch | ||||
|         { | ||||
|             DayOfWeek.Sunday => "D", | ||||
|             DayOfWeek.Monday => "L", | ||||
|             DayOfWeek.Tuesday => "M", | ||||
|             DayOfWeek.Wednesday => "X", | ||||
|             DayOfWeek.Thursday => "J", | ||||
|             DayOfWeek.Friday => "V", | ||||
|             DayOfWeek.Saturday => "S", | ||||
|             DayOfWeek.Sunday => "Dom", | ||||
|             DayOfWeek.Monday => "Lun", | ||||
|             DayOfWeek.Tuesday => "Mar", | ||||
|             DayOfWeek.Wednesday => "Mie", | ||||
|             DayOfWeek.Thursday => "Jue", | ||||
|             DayOfWeek.Friday => "Vie", | ||||
|             DayOfWeek.Saturday => "Sab", | ||||
|             _ => "" | ||||
|         }; | ||||
|  | ||||
| @@ -239,5 +515,14 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|             DayOfWeek.Saturday => precio.Sabado ?? 0, | ||||
|             _ => 0 | ||||
|         }; | ||||
|  | ||||
|         private decimal CalcularDescuentoPromociones(decimal importeBruto, IEnumerable<Promocion> promociones) | ||||
|         { | ||||
|             return promociones.Where(p => p.TipoEfecto.Contains("Descuento")).Sum(p => | ||||
|                p.TipoEfecto == "DescuentoPorcentajeTotal" | ||||
|                ? (importeBruto * p.ValorEfecto) / 100 | ||||
|                : p.ValorEfecto | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user