Feat: Implementa Reporte de Distribución de Suscripciones y Refactoriza Gestión de Ajustes
Se introduce una nueva funcionalidad de reporte crucial para la logística y se realiza una refactorización mayor del sistema de ajustes para garantizar la correcta imputación contable. ### ✨ Nuevas Características - **Nuevo Reporte de Distribución de Suscripciones (RR011):** - Se añade un nuevo reporte en PDF que genera un listado de todas las suscripciones activas en un rango de fechas. - El reporte está diseñado para el equipo de reparto, incluyendo datos clave como nombre del suscriptor, dirección, teléfono, días de entrega y observaciones. - Se implementa el endpoint, la lógica de servicio, la consulta a la base de datos y el template de QuestPDF en el backend. - Se crea la página correspondiente en el frontend (React) con su selector de fechas y se añade la ruta y el enlace en el menú de reportes. ### 🔄 Refactorización Mayor - **Asociación de Ajustes a Empresas:** - Se refactoriza por completo la entidad `Ajuste` para incluir una referencia obligatoria a una `IdEmpresa`. - **Motivo:** Corregir un error crítico en la lógica de negocio donde los ajustes de un suscriptor se aplicaban a la primera factura generada, sin importar a qué empresa correspondía el ajuste. - Se provee un script de migración SQL para actualizar el esquema de la base de datos (`susc_Ajustes`). - Se actualizan todos los DTOs, repositorios y servicios (backend) para manejar la nueva relación. - Se modifica el `FacturacionService` para que ahora aplique los ajustes pendientes correspondientes a cada empresa dentro de su bucle de facturación. - Se actualiza el formulario de creación/edición de ajustes en el frontend (React) para incluir un selector de empresa obligatorio. ### ⚡️ Optimizaciones de Rendimiento - **Solución de N+1 Queries:** - Se optimiza el método `ObtenerTodos` en `SuscriptorService` para obtener todas las formas de pago en una única consulta en lugar de una por cada suscriptor. - Se optimiza el método `ObtenerAjustesPorSuscriptor` en `AjusteService` para obtener todos los nombres de usuarios y empresas en dos consultas masivas, en lugar de N consultas individuales. - Se añade el método `GetByIdsAsync` al `IUsuarioRepository` y su implementación para soportar esta optimización. ### 🐛 Corrección de Errores - Se corrigen múltiples errores en el script de migración de base de datos (uso de `GO` dentro de transacciones, error de "columna inválida"). - Se soluciona un error de SQL (`INSERT` statement) en `AjusteRepository` que impedía la creación de nuevos ajustes. - Se corrige un bug en el `AjusteService` que causaba que el nombre de la empresa apareciera como "N/A" en la UI debido a un mapeo incompleto en el método optimizado. - Se corrige la lógica de generación de emails en `FacturacionService` para mostrar correctamente el nombre de la empresa en cada sección del resumen de cuenta.
This commit is contained in:
		| @@ -4,13 +4,6 @@ using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
| using System.Text; | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.AspNetCore.Http; | ||||
| using System.IO; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
| @@ -24,8 +17,8 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<DebitoAutomaticoService> _logger; | ||||
|  | ||||
|         private const string NRO_PRESTACION = "123456"; // Nro. de prestación asignado por el banco | ||||
|         private const string ORIGEN_EMPRESA = "ELDIA";   // Nombre de la empresa (7 chars) | ||||
|         private const string NRO_PRESTACION = "123456"; | ||||
|         private const string ORIGEN_EMPRESA = "ELDIA"; | ||||
|  | ||||
|         public DebitoAutomaticoService( | ||||
|             IFacturaRepository facturaRepository, | ||||
| @@ -47,6 +40,11 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|  | ||||
|         public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario) | ||||
|         { | ||||
|             // Se define la identificación del archivo. | ||||
|             // Este número debe ser gestionado para no repetirse en archivos generados | ||||
|             // para la misma prestación y fecha. | ||||
|             const int identificacionArchivo = 1; | ||||
|              | ||||
|             var periodo = $"{anio}-{mes:D2}"; | ||||
|             var fechaGeneracion = DateTime.Now; | ||||
|  | ||||
| @@ -64,7 +62,9 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|  | ||||
|                 var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal); | ||||
|                 var cantidadRegistros = facturasParaDebito.Count(); | ||||
|                 var nombreArchivo = $"PD_{ORIGEN_EMPRESA.Trim()}_{fechaGeneracion:yyyyMMdd}.txt"; | ||||
|                  | ||||
|                 // Se utiliza la variable 'identificacionArchivo' para nombrar el archivo. | ||||
|                 var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt"; | ||||
|  | ||||
|                 var nuevoLote = new LoteDebito | ||||
|                 { | ||||
| @@ -78,12 +78,14 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|                 if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito."); | ||||
|  | ||||
|                 var sb = new StringBuilder(); | ||||
|                 sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros)); | ||||
|                 // Se pasa la 'identificacionArchivo' al método que crea el Header. | ||||
|                 sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); | ||||
|                 foreach (var item in facturasParaDebito) | ||||
|                 { | ||||
|                     sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor)); | ||||
|                 } | ||||
|                 sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros)); | ||||
|                 // Se pasa la 'identificacionArchivo' al método que crea el Trailer. | ||||
|                 sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); | ||||
|  | ||||
|                 var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); | ||||
|                 bool actualizadas = await _facturaRepository.UpdateLoteDebitoAsync(idsFacturas, loteCreado.IdLoteDebito, transaction); | ||||
| @@ -103,13 +105,19 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|  | ||||
|         private async Task<List<(Factura Factura, Suscriptor Suscriptor)>> GetFacturasParaDebito(string periodo, IDbTransaction transaction) | ||||
|         { | ||||
|             var facturasDelPeriodo = await _facturaRepository.GetByPeriodoAsync(periodo); | ||||
|             var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); | ||||
|             var resultado = new List<(Factura, Suscriptor)>(); | ||||
|              | ||||
|             foreach (var f in facturasDelPeriodo.Where(fa => fa.EstadoPago == "Pendiente")) | ||||
|  | ||||
|             foreach (var f in facturas.Where(fa => fa.EstadoPago == "Pendiente")) | ||||
|             { | ||||
|                 var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor); | ||||
|                 if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU)) continue; | ||||
|  | ||||
|                 // Se valida que el CBU de Banelco (22 caracteres) exista antes de intentar la conversión. | ||||
|                 if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU) || suscriptor.CBU.Length != 22) | ||||
|                 { | ||||
|                     _logger.LogWarning("Suscriptor ID {IdSuscriptor} omitido del lote de débito por CBU inválido o ausente (se esperan 22 dígitos).", suscriptor?.IdSuscriptor); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); | ||||
|                 if (formaPago != null && formaPago.RequiereCBU) | ||||
| @@ -120,83 +128,119 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|             return resultado; | ||||
|         } | ||||
|  | ||||
|         // --- Métodos de Formateo de Campos --- | ||||
|         private string FormatString(string? value, int length) => (value ?? "").PadRight(length).Substring(0, length); | ||||
|         private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0'); | ||||
|         // Lógica de conversión de CBU. | ||||
|         private string ConvertirCbuBanelcoASnp(string cbu22) | ||||
|         { | ||||
|             if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) | ||||
|             { | ||||
|                 _logger.LogError("Se intentó convertir un CBU inválido de {Length} caracteres. Se devolverá un campo vacío.", cbu22?.Length ?? 0); | ||||
|                 // Devolver un string de 26 espacios/ceros según la preferencia del banco para campos erróneos. | ||||
|                 return "".PadRight(26);  | ||||
|             } | ||||
|  | ||||
|         private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros) | ||||
|             // El formato SNP de 26 se obtiene insertando un "0" al inicio y "000" después del 8vo caracter del CBU de 22. | ||||
|             // Formato Banelco (22): [BBBSSSSX] [T....Y] | ||||
|             // Posiciones:            (0-7)      (8-21) | ||||
|             // Formato SNP (26):      0[BBBSSSSX]000[T....Y] | ||||
|             try | ||||
|             { | ||||
|                 string bloque1 = cbu22.Substring(0, 8); // Contiene código de banco, sucursal y DV del bloque 1. | ||||
|                 string bloque2 = cbu22.Substring(8);    // Contiene el resto de la cadena. | ||||
|  | ||||
|                 // Reconstruir en formato SNP de 26 dígitos según el instructivo. | ||||
|                 return $"0{bloque1}000{bloque2}"; | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al parsear y convertir CBU de 22 dígitos: {CBU}", cbu22); | ||||
|                 return "".PadRight(26); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // --- Métodos de Formateo y Mapeo --- | ||||
|         private string FormatString(string? value, int length) => (value ?? "").PadRight(length); | ||||
|         private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0'); | ||||
|         private string MapTipoDocumento(string tipo) => tipo.ToUpper() switch | ||||
|         { | ||||
|             "DNI" => "0096", | ||||
|             "CUIT" => "0080", | ||||
|             "CUIL" => "0086", | ||||
|             "LE" => "0089", | ||||
|             "LC" => "0090", | ||||
|             _ => "0000" // Tipo no especificado o C.I. Policía Federal según anexo. | ||||
|         }; | ||||
|  | ||||
|         private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) | ||||
|         { | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("00"); // Tipo de Registro | ||||
|             sb.Append("00"); // Tipo de Registro Header | ||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); | ||||
|             sb.Append("C"); // Servicio | ||||
|             sb.Append("C"); // Servicio: Sistema Nacional de Pagos | ||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||
|             sb.Append("1"); // Identificación de Archivo (ej. '1' para el primer envío del día) | ||||
|             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo | ||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); // 12 enteros + 2 decimales | ||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); | ||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||
|             sb.Append(FormatString("", 304)); // Libre | ||||
|             sb.Append(FormatString("", 304)); | ||||
|             sb.Append("\r\n"); | ||||
|             return sb.ToString(); | ||||
|         } | ||||
|  | ||||
|         private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor) | ||||
|         { | ||||
|             // Convertimos el CBU de 22 (Banelco) a 26 (SNP) antes de usarlo. | ||||
|             string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!); | ||||
|  | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("0101"); // Tipo de Registro | ||||
|             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación Cliente | ||||
|             sb.Append(FormatString(suscriptor.CBU, 26)); // CBU | ||||
|  | ||||
|             // Referencia Unívoca: Usaremos ID Factura para asegurar unicidad | ||||
|             sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); | ||||
|  | ||||
|             sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); // Fecha 1er Vto | ||||
|             sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); // Importe 1er Vto | ||||
|  | ||||
|             // Campos opcionales o con valores fijos | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vto | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 2do Vto | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vto | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 3er Vto | ||||
|             sb.Append("0370"); // Tipo de Registro Detalle (Orden de Débito) | ||||
|             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación de Cliente | ||||
|             sb.Append(FormatString(cbu26, 26)); // CBU en formato SNP de 26 caracteres. | ||||
|             sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); // Referencia Unívoca de la factura. | ||||
|             sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); | ||||
|             sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento | ||||
|             sb.Append("0"); // Moneda (0 = Pesos) | ||||
|             sb.Append(FormatString("", 3)); // Motivo Rechazo | ||||
|             sb.Append(FormatString(suscriptor.TipoDocumento, 4)); | ||||
|             sb.Append(FormatString("", 3)); // Motivo Rechazo (vacío en el envío) | ||||
|             sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4)); | ||||
|             sb.Append(FormatString(suscriptor.NroDocumento, 11)); | ||||
|  | ||||
|             // El resto son campos opcionales que rellenamos con espacios/ceros | ||||
|             sb.Append(FormatString("", 22)); // Nueva ID Cliente | ||||
|             sb.Append(FormatNumeric(0, 26)); // Nuevo CBU | ||||
|             sb.Append(FormatString("", 26)); // Nueva CBU | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Mínimo | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vto | ||||
|             sb.Append(FormatString("", 22)); // ID Cuenta Anterior | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vencimiento | ||||
|             sb.Append(FormatString("", 22)); // Identificación Cuenta Anterior | ||||
|             sb.Append(FormatString("", 40)); // Mensaje ATM | ||||
|             sb.Append(FormatString($"Suscripcion {factura.Periodo}", 10)); // Concepto Factura | ||||
|             sb.Append(FormatString($"Susc.{factura.Periodo}", 10)); // Concepto Factura | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Cobrado | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Acreditación | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Acreditamiento | ||||
|             sb.Append(FormatString("", 26)); // Libre | ||||
|             sb.Append("\r\n"); | ||||
|             return sb.ToString(); | ||||
|         } | ||||
|  | ||||
|         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros) | ||||
|         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) | ||||
|         { | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("99"); // Tipo de Registro | ||||
|             sb.Append("99"); // Tipo de Registro Trailer | ||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); | ||||
|             sb.Append("C"); // Servicio | ||||
|             sb.Append("C"); // Servicio: Sistema Nacional de Pagos | ||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||
|             sb.Append("1"); // Identificación de Archivo | ||||
|             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo | ||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); | ||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||
|             sb.Append(FormatString("", 304)); // Libre | ||||
|             // No se añade \r\n al final del último registro | ||||
|             sb.Append(FormatString("", 304)); | ||||
|             // La última línea del archivo no lleva salto de línea (\r\n). | ||||
|             return sb.ToString(); | ||||
|         } | ||||
|  | ||||
|         public async Task<ProcesamientoLoteResponseDto> ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario) | ||||
|         { | ||||
|             // Se mantiene la lógica original para procesar el archivo de respuesta del banco. | ||||
|              | ||||
|             var respuesta = new ProcesamientoLoteResponseDto(); | ||||
|             if (archivo == null || archivo.Length == 0) | ||||
|             { | ||||
| @@ -237,7 +281,7 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|                     { | ||||
|                         IdFactura = idFactura, | ||||
|                         FechaPago = DateTime.Now.Date, | ||||
|                         IdFormaPago = 1, | ||||
|                         IdFormaPago = 1, // Se asume una forma de pago para el débito. | ||||
|                         Monto = factura.ImporteFinal, | ||||
|                         IdUsuarioRegistro = idUsuario, | ||||
|                         Referencia = $"Lote {factura.IdLoteDebito} - Banco" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user