Feat: Mejora UI de Cuenta Corriente y corrige colores en email de aviso
Este commit introduce significativas mejoras de usabilidad en la página de gestión de ajustes del suscriptor y corrige la representación visual de los ajustes en el email de notificación mensual. ### ✨ Nuevas Características y Mejoras de UI - **Nuevos Filtros en Cuenta Corriente del Suscriptor:** - Se han añadido nuevos filtros desplegables en la página de "Cuenta Corriente" para filtrar los ajustes por **Estado** (`Pendiente`, `Aplicado`, `Anulado`) y por **Tipo** (`Crédito`, `Débito`). - Esta mejora permite a los usuarios encontrar registros específicos de manera mucho más rápida y eficiente, especialmente para suscriptores con un largo historial de ajustes. - **Visualización Mejorada del Estado de Ajuste:** - La columna "Estado" en la tabla de ajustes ahora muestra el número de factura oficial (ej. `A-0001-12345`) si un ajuste ha sido aplicado y la factura ya está numerada. - Si la factura aún no tiene un número oficial, se muestra una referencia al ID interno (ej. `ID Interno #64`) para mantener la trazabilidad. - Para soportar esto, se ha enriquecido el `AjusteDto` en el backend para incluir el `NumeroFacturaAplicado`. ### 🐛 Corrección y Refactorización - **Corrección de Colores en Email de Aviso:** - Se han invertido los colores de los montos de ajuste en el email de aviso mensual enviado al cliente para alinearlos con la perspectiva del usuario. - **Créditos** (descuentos a favor del cliente) ahora se muestran en **verde** (positivo). - **Débitos** (cargos extra) ahora se muestran en **rojo** (negativo). - Este cambio mejora drásticamente la claridad del resumen de cuenta y evita posibles confusiones. ### ⚙️ Cambios Técnicos de Soporte - Se ha añadido el método `GetByIdsAsync` al `IFacturaRepository` para optimizar la obtención de datos de múltiples facturas en una sola consulta, evitando el problema N+1. - El `AjusteService` ha sido actualizado para utilizar este nuevo método y poblar eficientemente la información de la factura en el DTO de ajuste que se envía al frontend.
This commit is contained in:
@@ -244,5 +244,16 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
return Enumerable.Empty<Factura>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Factura>> GetByIdsAsync(IEnumerable<int> ids)
|
||||
{
|
||||
if (ids == null || !ids.Any())
|
||||
{
|
||||
return Enumerable.Empty<Factura>();
|
||||
}
|
||||
const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdFactura IN @Ids;";
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
return await connection.QueryAsync<Factura>(sql, new { Ids = ids });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
|
||||
public interface IFacturaRepository
|
||||
{
|
||||
Task<Factura?> GetByIdAsync(int idFactura);
|
||||
Task<IEnumerable<Factura>> GetByIdsAsync(IEnumerable<int> ids);
|
||||
Task<IEnumerable<Factura>> GetByPeriodoAsync(string periodo);
|
||||
Task<Factura?> GetBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo, IDbTransaction transaction);
|
||||
Task<IEnumerable<Factura>> GetListBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo);
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
public string Motivo { get; set; } = string.Empty;
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
public int? IdFacturaAplicado { get; set; }
|
||||
public string? NumeroFacturaAplicado { get; set; }
|
||||
public string FechaAlta { get; set; } = string.Empty;
|
||||
public string NombreUsuarioAlta { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
private readonly ISuscriptorRepository _suscriptorRepository;
|
||||
private readonly IUsuarioRepository _usuarioRepository;
|
||||
private readonly IEmpresaRepository _empresaRepository;
|
||||
private readonly IFacturaRepository _facturaRepository;
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<AjusteService> _logger;
|
||||
|
||||
@@ -22,6 +23,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
ISuscriptorRepository suscriptorRepository,
|
||||
IUsuarioRepository usuarioRepository,
|
||||
IEmpresaRepository empresaRepository,
|
||||
IFacturaRepository facturaRepository,
|
||||
DbConnectionFactory connectionFactory,
|
||||
ILogger<AjusteService> logger)
|
||||
{
|
||||
@@ -29,6 +31,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
_suscriptorRepository = suscriptorRepository;
|
||||
_usuarioRepository = usuarioRepository;
|
||||
_empresaRepository = empresaRepository;
|
||||
_facturaRepository = facturaRepository;
|
||||
_connectionFactory = connectionFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -63,27 +66,34 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
return Enumerable.Empty<AjusteDto>();
|
||||
}
|
||||
|
||||
// 1. Recolectar IDs únicos de usuarios Y empresas de la lista de ajustes
|
||||
// 1. Recolectar IDs de usuarios, empresas Y FACTURAS
|
||||
var idsUsuarios = ajustes.Select(a => a.IdUsuarioAlta).Distinct().ToList();
|
||||
var idsEmpresas = ajustes.Select(a => a.IdEmpresa).Distinct().ToList();
|
||||
var idsFacturas = ajustes.Where(a => a.IdFacturaAplicado.HasValue)
|
||||
.Select(a => a.IdFacturaAplicado!.Value)
|
||||
.Distinct().ToList();
|
||||
|
||||
// 2. Obtener todos los usuarios y empresas necesarios en dos consultas masivas.
|
||||
// 2. Obtener todos los datos necesarios en consultas masivas
|
||||
var usuariosTask = _usuarioRepository.GetByIdsAsync(idsUsuarios);
|
||||
var empresasTask = _empresaRepository.GetAllAsync(null, null); // Asumiendo que GetAllAsync es suficiente o crear un GetByIds.
|
||||
var empresasTask = _empresaRepository.GetAllAsync(null, null);
|
||||
var facturasTask = _facturaRepository.GetByIdsAsync(idsFacturas);
|
||||
|
||||
// Esperamos a que ambas consultas terminen
|
||||
await Task.WhenAll(usuariosTask, empresasTask);
|
||||
await Task.WhenAll(usuariosTask, empresasTask, facturasTask);
|
||||
|
||||
// Convertimos los resultados a diccionarios para búsqueda rápida
|
||||
// 3. Convertir a diccionarios para búsqueda rápida
|
||||
var usuariosDict = (await usuariosTask).ToDictionary(u => u.Id);
|
||||
var empresasDict = (await empresasTask).ToDictionary(e => e.IdEmpresa);
|
||||
var facturasDict = (await facturasTask).ToDictionary(f => f.IdFactura);
|
||||
|
||||
// 3. Mapear en memoria, ahora con toda la información disponible.
|
||||
// 4. Mapear en memoria, ahora con la información de la factura disponible
|
||||
var dtos = ajustes.Select(ajuste =>
|
||||
{
|
||||
usuariosDict.TryGetValue(ajuste.IdUsuarioAlta, out var usuario);
|
||||
empresasDict.TryGetValue(ajuste.IdEmpresa, out var empresa);
|
||||
|
||||
// Buscar la factura en el diccionario si el ajuste está aplicado
|
||||
facturasDict.TryGetValue(ajuste.IdFacturaAplicado ?? 0, out var factura);
|
||||
|
||||
return new AjusteDto
|
||||
{
|
||||
IdAjuste = ajuste.IdAjuste,
|
||||
@@ -96,6 +106,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
Motivo = ajuste.Motivo,
|
||||
Estado = ajuste.Estado,
|
||||
IdFacturaAplicado = ajuste.IdFacturaAplicado,
|
||||
NumeroFacturaAplicado = factura?.NumeroFactura,
|
||||
FechaAlta = ajuste.FechaAlta.ToString("yyyy-MM-dd HH:mm"),
|
||||
NombreUsuarioAlta = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A"
|
||||
};
|
||||
@@ -116,7 +127,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
return (null, "La empresa especificada no existe.");
|
||||
}
|
||||
|
||||
|
||||
var nuevoAjuste = new Ajuste
|
||||
{
|
||||
IdSuscriptor = createDto.IdSuscriptor,
|
||||
@@ -186,7 +197,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
var ajuste = await _ajusteRepository.GetByIdAsync(idAjuste);
|
||||
if (ajuste == null) return (false, "Ajuste no encontrado.");
|
||||
if (ajuste.Estado != "Pendiente") return (false, $"No se puede modificar un ajuste en estado '{ajuste.Estado}'.");
|
||||
|
||||
|
||||
var empresa = await _empresaRepository.GetByIdAsync(updateDto.IdEmpresa);
|
||||
if (empresa == null) return (false, "La empresa especificada no existe.");
|
||||
|
||||
|
||||
@@ -310,85 +310,80 @@ namespace GestionIntegral.Api.Services.Suscripciones
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor)
|
||||
{
|
||||
var periodo = $"{anio}-{mes:D2}";
|
||||
try
|
||||
{
|
||||
var periodo = $"{anio}-{mes:D2}";
|
||||
try
|
||||
var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo);
|
||||
if (!facturasConEmpresa.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 item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada"))
|
||||
{
|
||||
// 1. Reemplazamos la llamada original por la nueva, que ya trae toda la información necesaria.
|
||||
var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo);
|
||||
if (!facturasConEmpresa.Any()) return (false, "No se encontraron facturas para este suscriptor en el período.");
|
||||
var factura = item.Factura;
|
||||
var nombreEmpresa = item.NombreEmpresa;
|
||||
|
||||
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 detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura);
|
||||
|
||||
var resumenHtml = new StringBuilder();
|
||||
var adjuntos = new List<(byte[] content, string name)>();
|
||||
|
||||
// 2. Iteramos sobre la nueva lista de tuplas.
|
||||
foreach (var item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada"))
|
||||
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)
|
||||
{
|
||||
var factura = item.Factura;
|
||||
var nombreEmpresa = item.NombreEmpresa;
|
||||
|
||||
// 3. Eliminamos la lógica compleja y propensa a errores para obtener la empresa.
|
||||
// La llamada a GetDetallesPorFacturaIdAsync sigue siendo necesaria para el cuerpo del email.
|
||||
var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura);
|
||||
|
||||
// Título mejorado para claridad
|
||||
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;'>");
|
||||
|
||||
// 1. Mostrar Detalles de Suscripciones
|
||||
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)
|
||||
{
|
||||
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 ? "#d9534f" : "#5cb85c";
|
||||
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);
|
||||
// Usamos el nombre de la empresa para un nombre de archivo más claro
|
||||
string pdfFileName = $"Factura_{nombreEmpresa.Replace(" ", "")}_{factura.NumeroFactura}.pdf";
|
||||
adjuntos.Add((pdfBytes, pdfFileName));
|
||||
_logger.LogInformation("PDF adjuntado: {FileName}", pdfFileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta);
|
||||
}
|
||||
bool esCredito = ajuste.TipoAjuste == "Credito";
|
||||
string colorMonto = esCredito ? "#5cb85c" : "#d9534f";
|
||||
string signo = esCredito ? "-" : "+";
|
||||
resumenHtml.Append($"<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>");
|
||||
|
||||
var totalGeneral = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.ImporteFinal);
|
||||
string asunto = $"Resumen de Cuenta - Diario El Día - Período {periodo}";
|
||||
string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral);
|
||||
if (!string.IsNullOrEmpty(factura.NumeroFactura))
|
||||
{
|
||||
var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
|
||||
if (File.Exists(rutaCompleta))
|
||||
{
|
||||
byte[] pdfBytes = await File.ReadAllBytesAsync(rutaCompleta);
|
||||
string pdfFileName = $"Factura_{nombreEmpresa.Replace(" ", "")}_{factura.NumeroFactura}.pdf";
|
||||
adjuntos.Add((pdfBytes, pdfFileName));
|
||||
_logger.LogInformation("PDF adjuntado: {FileName}", pdfFileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _emailService.EnviarEmailConsolidadoAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, adjuntos);
|
||||
_logger.LogInformation("Email consolidado para Suscriptor ID {IdSuscriptor} enviado para el período {Periodo}.", idSuscriptor, periodo);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Falló el envío de email consolidado para el suscriptor ID {IdSuscriptor} y período {Periodo}", idSuscriptor, periodo);
|
||||
return (false, "Ocurrió un error al intentar enviar el email consolidado.");
|
||||
}
|
||||
var totalGeneral = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.ImporteFinal);
|
||||
string asunto = $"Resumen de Cuenta - 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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user