All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m45s
Se introduce una refactorización mayor del ciclo de facturación para manejar correctamente las suscripciones que inician en un período ya cerrado. Esto soluciona el problema de cobrar un mes completo a un nuevo suscriptor, mejorando la transparencia y la experiencia del cliente. ### ✨ Nuevas Características y Lógica de Negocio - **Facturación Pro-rata Automática (Factura de Alta):** - Al crear una nueva suscripción cuya fecha de inicio corresponde a un período de facturación ya cerrado, el sistema ahora calcula automáticamente el costo proporcional por los días restantes de ese mes. - Se genera de forma inmediata una nueva factura de tipo "Alta" por este monto parcial, separándola del ciclo de facturación mensual regular. - **Exclusión del Débito Automático para Facturas de Alta:** - Se implementa una regla de negocio clave: las facturas de tipo "Alta" son **excluidas** del proceso de generación del archivo de débito automático para el banco. - Esto fuerza a que el primer cobro (el proporcional) se gestione a través de un medio de pago manual (efectivo, transferencia, etc.), evitando cargos inesperados en la cuenta bancaria del cliente. - El débito automático comenzará a operar normalmente a partir del primer ciclo de facturación completo. ### 🔄 Cambios en el Backend - **Base de Datos:** - Se ha añadido la columna `TipoFactura` (`varchar(20)`) a la tabla `susc_Facturas`. - Se ha implementado una `CHECK constraint` para permitir únicamente los valores 'Mensual' y 'Alta'. - **Servicios:** - **`SuscripcionService`:** Ahora contiene la lógica para detectar una alta retroactiva, invocar al `FacturacionService` para el cálculo pro-rata y crear la "Factura de Alta" y su detalle correspondiente dentro de la misma transacción. - **`FacturacionService`:** Expone públicamente el método `CalcularImporteParaSuscripcion` y se ha actualizado `ObtenerResumenesDeCuentaPorPeriodo` para que envíe la propiedad `TipoFactura` al frontend. - **`DebitoAutomaticoService`:** El método `GetFacturasParaDebito` ahora filtra y excluye explícitamente las facturas donde `TipoFactura = 'Alta'`. ### 🎨 Mejoras en la Interfaz de Usuario (Frontend) - **`ConsultaFacturasPage.tsx`:** - **Nueva Columna:** Se ha añadido una columna "Tipo Factura" en la tabla de detalle, que muestra un `Chip` distintivo para identificar fácilmente las facturas de "Alta". - **Nuevo Filtro:** Se ha agregado un nuevo menú desplegable para filtrar la vista por "Tipo de Factura" (`Todas`, `Mensual`, `Alta`), permitiendo a los administradores auditar rápidamente los nuevos ingresos.
678 lines
38 KiB
C#
678 lines
38 KiB
C#
using GestionIntegral.Api.Data;
|
|
using GestionIntegral.Api.Data.Repositories.Distribucion;
|
|
using GestionIntegral.Api.Data.Repositories.Suscripciones;
|
|
using GestionIntegral.Api.Dtos.Suscripciones;
|
|
using GestionIntegral.Api.Models.Distribucion;
|
|
using GestionIntegral.Api.Models.Suscripciones;
|
|
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;
|
|
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
|
|
private readonly IPrecioRepository _precioRepository;
|
|
private readonly IPromocionRepository _promocionRepository;
|
|
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;
|
|
private const string LogoUrl = "https://www.eldia.com/img/header/eldia.png";
|
|
|
|
public FacturacionService(
|
|
ISuscripcionRepository suscripcionRepository,
|
|
IFacturaRepository facturaRepository,
|
|
IEmpresaRepository empresaRepository,
|
|
IFacturaDetalleRepository facturaDetalleRepository,
|
|
IPrecioRepository precioRepository,
|
|
IPromocionRepository promocionRepository,
|
|
ISuscriptorRepository suscriptorRepository,
|
|
IAjusteRepository ajusteRepository,
|
|
IEmailService emailService,
|
|
IPublicacionRepository publicacionRepository,
|
|
DbConnectionFactory connectionFactory,
|
|
ILogger<FacturacionService> logger,
|
|
IConfiguration configuration,
|
|
ILoteDeEnvioRepository loteDeEnvioRepository,
|
|
IUsuarioRepository usuarioRepository)
|
|
{
|
|
_loteDeEnvioRepository = loteDeEnvioRepository;
|
|
_usuarioRepository = usuarioRepository;
|
|
_suscripcionRepository = suscripcionRepository;
|
|
_facturaRepository = facturaRepository;
|
|
_empresaRepository = empresaRepository;
|
|
_facturaDetalleRepository = facturaDetalleRepository;
|
|
_precioRepository = precioRepository;
|
|
_promocionRepository = promocionRepository;
|
|
_suscriptorRepository = suscriptorRepository;
|
|
_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, 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)
|
|
{
|
|
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}.", null);
|
|
}
|
|
}
|
|
|
|
var facturasCreadas = new List<Factura>();
|
|
int facturasGeneradas = 0;
|
|
int emailsEnviados = 0;
|
|
int emailsFallidos = 0;
|
|
var erroresDetallados = new List<EmailLogDto>();
|
|
|
|
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())
|
|
{
|
|
// 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);
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa });
|
|
|
|
foreach (var grupo in gruposParaFacturar)
|
|
{
|
|
int idSuscriptor = grupo.Key.IdSuscriptor;
|
|
int idEmpresa = grupo.Key.IdEmpresa;
|
|
decimal importeBrutoTotal = 0;
|
|
decimal descuentoPromocionesTotal = 0;
|
|
var detallesParaFactura = new List<FacturaDetalle>();
|
|
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
|
|
});
|
|
}
|
|
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);
|
|
var importeFinal = importeBrutoTotal - descuentoPromocionesTotal + totalAjustes;
|
|
if (importeFinal < 0) importeFinal = 0;
|
|
if (importeBrutoTotal <= 0 && descuentoPromocionesTotal <= 0 && totalAjustes == 0) continue;
|
|
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 (ajustesPendientes.Any())
|
|
{
|
|
await _ajusteRepository.MarcarAjustesComoAplicadosAsync(ajustesPendientes.Select(a => a.IdAjuste), facturaCreada.IdFactura, transaction);
|
|
}
|
|
facturasGeneradas++;
|
|
}
|
|
|
|
transaction.Commit();
|
|
_logger.LogInformation("Finalizada la generación de {FacturasGeneradas} facturas para {Periodo}.", facturasGeneradas, periodoActualStr);
|
|
|
|
if (facturasCreadas.Any())
|
|
{
|
|
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
|
|
{
|
|
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);
|
|
}
|
|
|
|
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.", 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<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes)
|
|
{
|
|
var lotes = await _loteDeEnvioRepository.GetAllAsync(anio, mes);
|
|
if (!lotes.Any())
|
|
{
|
|
return Enumerable.Empty<LoteDeEnvioHistorialDto>();
|
|
}
|
|
|
|
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<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(
|
|
int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura)
|
|
{
|
|
var periodo = $"{anio}-{mes:D2}";
|
|
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura);
|
|
var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo);
|
|
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,
|
|
TotalPagado = itemFactura.TotalPagado,
|
|
|
|
// Faltaba esta línea para pasar el tipo de factura al frontend.
|
|
TipoFactura = itemFactura.Factura.TipoFactura,
|
|
|
|
Detalles = detallesData
|
|
.Where(d => d.IdFactura == itemFactura.Factura.IdFactura)
|
|
.Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto })
|
|
.ToList(),
|
|
|
|
// Pasamos el id del suscriptor para facilitar las cosas en el frontend
|
|
IdSuscriptor = itemFactura.Factura.IdSuscriptor
|
|
};
|
|
}).ToList();
|
|
|
|
return new ResumenCuentaSuscriptorDto
|
|
{
|
|
IdSuscriptor = primerItem.Factura.IdSuscriptor,
|
|
NombreSuscriptor = primerItem.NombreSuscriptor,
|
|
Facturas = facturasConsolidadas,
|
|
ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal),
|
|
SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal - f.TotalPagado)
|
|
};
|
|
});
|
|
|
|
return resumenes.ToList();
|
|
}
|
|
|
|
public async Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario)
|
|
{
|
|
try
|
|
{
|
|
var factura = await _facturaRepository.GetByIdAsync(idFactura);
|
|
if (factura == null) return (false, "Factura no encontrada.", null);
|
|
if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura no tiene un número asignado.", null);
|
|
if (factura.EstadoPago == "Anulada") return (false, "No se puede enviar email de una factura anulada.", null);
|
|
|
|
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.", null);
|
|
|
|
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_{factura.NumeroFactura}.pdf";
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura}", factura.NumeroFactura);
|
|
return (false, "No se encontró el archivo PDF correspondiente en el servidor.", null);
|
|
}
|
|
|
|
string asunto = $"Factura Electrónica - Período {factura.Periodo}";
|
|
string cuerpoHtml = ConstruirCuerpoEmailFacturaPdf(suscriptor, factura);
|
|
|
|
// Pasamos los nuevos parámetros de contexto al EmailService.
|
|
await _emailService.EnviarEmailAsync(
|
|
destinatarioEmail: suscriptor.Email,
|
|
destinatarioNombre: suscriptor.NombreCompleto,
|
|
asunto: asunto,
|
|
cuerpoHtml: cuerpoHtml,
|
|
attachment: pdfAttachment,
|
|
attachmentName: pdfFileName,
|
|
origen: "EnvioManualPDF",
|
|
referenciaId: $"Factura-{idFactura}",
|
|
idUsuarioDisparo: idUsuario);
|
|
|
|
_logger.LogInformation("Email con factura PDF ID {IdFactura} enviado para Suscriptor ID {IdSuscriptor}", idFactura, suscriptor.IdSuscriptor);
|
|
|
|
return (true, null, suscriptor.Email);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Falló el envío de email con PDF para la factura ID {IdFactura}", idFactura);
|
|
// El error ya será logueado por EmailService, pero lo relanzamos para que el controller lo maneje.
|
|
// En este caso, simplemente devolvemos la tupla de error.
|
|
return (false, "Ocurrió un error al intentar enviar el email con la factura.", null);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor, int idLoteDeEnvio, int idUsuarioDisparo)
|
|
{
|
|
var periodo = $"{anio}-{mes:D2}";
|
|
|
|
// 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())
|
|
{
|
|
// 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);
|
|
// 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)>();
|
|
|
|
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($"<h4 style='margin-top: 20px; margin-bottom: 10px; color: #34515e;'>Resumen para {nombreEmpresa}</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>");
|
|
}
|
|
|
|
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($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee; font-style: italic;'>Ajuste: {ajuste.Motivo}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right; color: {colorMonto}; font-style: italic;'>{signo} ${ajuste.Monto: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_{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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
return $@"
|
|
<div style='font-family: Arial, sans-serif; background-color: #f9f9f9; padding: 20px;'>
|
|
<div style='max-width: 600px; margin: auto; background-color: #ffffff; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'>
|
|
<div style='background-color: #34515e; color: #ffffff; padding: 20px; text-align: center;'>
|
|
<img src='{LogoUrl}' alt='El Día' style='max-width: 150px; margin-bottom: 10px;'>
|
|
<h2>Resumen de su Cuenta</h2>
|
|
</div>
|
|
<div style='padding: 20px; color: #333;'>
|
|
<h3 style='color: #34515e;'>Hola {suscriptor.NombreCompleto},</h3>
|
|
<p>Le enviamos el resumen de su cuenta para el período <strong>{periodo}</strong>.</p>
|
|
|
|
<!-- Aquí se insertan las tablas de resumen generadas dinámicamente -->
|
|
{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 A ABONAR:</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>
|
|
</div>
|
|
<div style='background-color: #f2f2f2; padding: 15px; text-align: center; font-size: 0.8em; color: #777;'>
|
|
<p>Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.</p>
|
|
<p>© {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.</p>
|
|
</div>
|
|
</div>
|
|
</div>";
|
|
}
|
|
|
|
private string ConstruirCuerpoEmailFacturaPdf(Suscriptor suscriptor, Factura factura)
|
|
{
|
|
return $@"
|
|
<div style='font-family: Arial, sans-serif; background-color: #f9f9f9; padding: 20px;'>
|
|
<div style='max-width: 600px; margin: auto; background-color: #ffffff; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'>
|
|
<div style='background-color: #34515e; color: #ffffff; padding: 20px; text-align: center;'>
|
|
<img src='{LogoUrl}' alt='El Día' style='max-width: 150px; margin-bottom: 10px;'>
|
|
<h2>Factura Electrónica Adjunta</h2>
|
|
</div>
|
|
<div style='padding: 20px; color: #333;'>
|
|
<h3 style='color: #34515e;'>Hola {suscriptor.NombreCompleto},</h3>
|
|
<p>Le enviamos adjunta su factura correspondiente al período <strong>{factura.Periodo}</strong>.</p>
|
|
<h4 style='border-bottom: 2px solid #34515e; padding-bottom: 5px; margin-top: 30px;'>Resumen de la Factura</h4>
|
|
<table style='width: 100%; border-collapse: collapse; margin-top: 15px;'>
|
|
<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Número de Factura:</td><td style='padding: 8px; text-align: right;'>{factura.NumeroFactura}</td></tr>
|
|
<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Período:</td><td style='padding: 8px; text-align: right;'>{factura.Periodo}</td></tr>
|
|
<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Fecha de Envío:</td><td style='padding: 8px; text-align: right;'>{factura.FechaEmision:dd/MM/yyyy}</td></tr>
|
|
<tr style='background-color: #f2f2f2;'><td style='padding: 12px; font-weight: bold; font-size: 1.1em;'>IMPORTE TOTAL:</td><td style='padding: 12px; text-align: right; font-weight: bold; font-size: 1.2em; color: #34515e;'>${factura.ImporteFinal:N2}</td></tr>
|
|
</table>
|
|
<p style='margin-top: 30px;'>Puede descargar y guardar el archivo PDF adjunto para sus registros.</p>
|
|
<p>Gracias por ser parte de nuestra comunidad de lectores.</p>
|
|
</div>
|
|
<div style='background-color: #f2f2f2; padding: 15px; text-align: center; font-size: 0.8em; color: #777;'>
|
|
<p>Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.</p>
|
|
<p>© {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.</p>
|
|
</div>
|
|
</div>
|
|
</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();
|
|
using var transaction = connection.BeginTransaction();
|
|
|
|
try
|
|
{
|
|
var factura = await _facturaRepository.GetByIdAsync(idFactura);
|
|
if (factura == null)
|
|
{
|
|
return (false, "La factura especificada no existe.");
|
|
}
|
|
if (factura.EstadoPago == "Anulada")
|
|
{
|
|
return (false, "No se puede modificar una factura anulada.");
|
|
}
|
|
|
|
var actualizado = await _facturaRepository.UpdateNumeroFacturaAsync(idFactura, numeroFactura, transaction);
|
|
if (!actualizado)
|
|
{
|
|
throw new DataException("La actualización del número de factura falló en el repositorio.");
|
|
}
|
|
|
|
transaction.Commit();
|
|
_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 al actualizar número de factura para Factura ID {IdFactura}", idFactura);
|
|
return (false, "Error interno al actualizar el número de factura.");
|
|
}
|
|
}
|
|
|
|
public async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction)
|
|
{
|
|
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)
|
|
{
|
|
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)
|
|
{
|
|
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);
|
|
}
|
|
return importeTotal;
|
|
}
|
|
|
|
private bool EvaluarCondicionPromocion(Promocion promocion, DateTime fecha)
|
|
{
|
|
switch (promocion.TipoCondicion)
|
|
{
|
|
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 => "Dom",
|
|
DayOfWeek.Monday => "Lun",
|
|
DayOfWeek.Tuesday => "Mar",
|
|
DayOfWeek.Wednesday => "Mie",
|
|
DayOfWeek.Thursday => "Jue",
|
|
DayOfWeek.Friday => "Vie",
|
|
DayOfWeek.Saturday => "Sab",
|
|
_ => ""
|
|
};
|
|
|
|
private decimal GetPrecioDelDia(Precio precio, DayOfWeek dia) => dia switch
|
|
{
|
|
DayOfWeek.Sunday => precio.Domingo ?? 0,
|
|
DayOfWeek.Monday => precio.Lunes ?? 0,
|
|
DayOfWeek.Tuesday => precio.Martes ?? 0,
|
|
DayOfWeek.Wednesday => precio.Miercoles ?? 0,
|
|
DayOfWeek.Thursday => precio.Jueves ?? 0,
|
|
DayOfWeek.Friday => precio.Viernes ?? 0,
|
|
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
|
|
);
|
|
}
|
|
}
|
|
} |