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