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.
		
			
				
	
	
		
			320 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			320 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using GestionIntegral.Api.Data;
 | |
| using GestionIntegral.Api.Data.Repositories.Suscripciones;
 | |
| using GestionIntegral.Api.Models.Suscripciones;
 | |
| using System.Data;
 | |
| using System.Text;
 | |
| using GestionIntegral.Api.Dtos.Suscripciones;
 | |
| 
 | |
| namespace GestionIntegral.Api.Services.Suscripciones
 | |
| {
 | |
|     public class DebitoAutomaticoService : IDebitoAutomaticoService
 | |
|     {
 | |
|         private readonly IFacturaRepository _facturaRepository;
 | |
|         private readonly ISuscriptorRepository _suscriptorRepository;
 | |
|         private readonly ILoteDebitoRepository _loteDebitoRepository;
 | |
|         private readonly IFormaPagoRepository _formaPagoRepository;
 | |
|         private readonly IPagoRepository _pagoRepository;
 | |
|         private readonly DbConnectionFactory _connectionFactory;
 | |
|         private readonly ILogger<DebitoAutomaticoService> _logger;
 | |
| 
 | |
|         private const string NRO_PRESTACION = "123456";
 | |
|         private const string ORIGEN_EMPRESA = "ELDIA";
 | |
| 
 | |
|         public DebitoAutomaticoService(
 | |
|             IFacturaRepository facturaRepository,
 | |
|             ISuscriptorRepository suscriptorRepository,
 | |
|             ILoteDebitoRepository loteDebitoRepository,
 | |
|             IFormaPagoRepository formaPagoRepository,
 | |
|             IPagoRepository pagoRepository,
 | |
|             DbConnectionFactory connectionFactory,
 | |
|             ILogger<DebitoAutomaticoService> logger)
 | |
|         {
 | |
|             _facturaRepository = facturaRepository;
 | |
|             _suscriptorRepository = suscriptorRepository;
 | |
|             _loteDebitoRepository = loteDebitoRepository;
 | |
|             _formaPagoRepository = formaPagoRepository;
 | |
|             _pagoRepository = pagoRepository;
 | |
|             _connectionFactory = connectionFactory;
 | |
|             _logger = logger;
 | |
|         }
 | |
| 
 | |
|         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;
 | |
| 
 | |
|             using var connection = _connectionFactory.CreateConnection();
 | |
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync();
 | |
|             using var transaction = connection.BeginTransaction();
 | |
| 
 | |
|             try
 | |
|             {
 | |
|                 var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction);
 | |
|                 if (!facturasParaDebito.Any())
 | |
|                 {
 | |
|                     return (null, null, "No se encontraron facturas pendientes de cobro por débito automático para el período seleccionado.");
 | |
|                 }
 | |
| 
 | |
|                 var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal);
 | |
|                 var cantidadRegistros = facturasParaDebito.Count();
 | |
|                 
 | |
|                 // Se utiliza la variable 'identificacionArchivo' para nombrar el archivo.
 | |
|                 var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt";
 | |
| 
 | |
|                 var nuevoLote = new LoteDebito
 | |
|                 {
 | |
|                     Periodo = periodo,
 | |
|                     NombreArchivo = nombreArchivo,
 | |
|                     ImporteTotal = importeTotal,
 | |
|                     CantidadRegistros = cantidadRegistros,
 | |
|                     IdUsuarioGeneracion = idUsuario
 | |
|                 };
 | |
|                 var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction);
 | |
|                 if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito.");
 | |
| 
 | |
|                 var sb = new StringBuilder();
 | |
|                 // 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));
 | |
|                 }
 | |
|                 // 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);
 | |
|                 if (!actualizadas) throw new DataException("No se pudieron actualizar las facturas con la información del lote.");
 | |
| 
 | |
|                 transaction.Commit();
 | |
|                 _logger.LogInformation("Archivo de débito {NombreArchivo} generado exitosamente para el período {Periodo}.", nombreArchivo, periodo);
 | |
|                 return (sb.ToString(), nombreArchivo, null);
 | |
|             }
 | |
|             catch (Exception ex)
 | |
|             {
 | |
|                 try { transaction.Rollback(); } catch { }
 | |
|                 _logger.LogError(ex, "Error crítico al generar el archivo de débito para el período {Periodo}", periodo);
 | |
|                 return (null, null, $"Error interno: {ex.Message}");
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         private async Task<List<(Factura Factura, Suscriptor Suscriptor)>> GetFacturasParaDebito(string periodo, IDbTransaction transaction)
 | |
|         {
 | |
|             var facturas = await _facturaRepository.GetByPeriodoAsync(periodo);
 | |
|             var resultado = new List<(Factura, Suscriptor)>();
 | |
| 
 | |
|             foreach (var f in facturas.Where(fa => fa.EstadoPago == "Pendiente"))
 | |
|             {
 | |
|                 var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor);
 | |
| 
 | |
|                 // 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)
 | |
|                 {
 | |
|                     resultado.Add((f, suscriptor));
 | |
|                 }
 | |
|             }
 | |
|             return resultado;
 | |
|         }
 | |
| 
 | |
|         // 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); 
 | |
|             }
 | |
| 
 | |
|             // 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 Header
 | |
|             sb.Append(FormatString(NRO_PRESTACION, 6));
 | |
|             sb.Append("C"); // Servicio: Sistema Nacional de Pagos
 | |
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd"));
 | |
|             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));
 | |
|             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("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 (vacío en el envío)
 | |
|             sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4));
 | |
|             sb.Append(FormatString(suscriptor.NroDocumento, 11));
 | |
|             sb.Append(FormatString("", 22)); // Nueva ID Cliente
 | |
|             sb.Append(FormatString("", 26)); // Nueva CBU
 | |
|             sb.Append(FormatNumeric(0, 14)); // Importe Mínimo
 | |
|             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($"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 de Acreditamiento
 | |
|             sb.Append(FormatString("", 26)); // Libre
 | |
|             sb.Append("\r\n");
 | |
|             return sb.ToString();
 | |
|         }
 | |
| 
 | |
|         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo)
 | |
|         {
 | |
|             var sb = new StringBuilder();
 | |
|             sb.Append("99"); // Tipo de Registro Trailer
 | |
|             sb.Append(FormatString(NRO_PRESTACION, 6));
 | |
|             sb.Append("C"); // Servicio: Sistema Nacional de Pagos
 | |
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd"));
 | |
|             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));
 | |
|             // 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)
 | |
|             {
 | |
|                 respuesta.Errores.Add("No se proporcionó ningún archivo o el archivo está vacío.");
 | |
|                 return respuesta;
 | |
|             }
 | |
| 
 | |
|             using var connection = _connectionFactory.CreateConnection();
 | |
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync();
 | |
|             using var transaction = connection.BeginTransaction();
 | |
| 
 | |
|             try
 | |
|             {
 | |
|                 using var reader = new StreamReader(archivo.OpenReadStream());
 | |
|                 string? linea;
 | |
|                 while ((linea = await reader.ReadLineAsync()) != null)
 | |
|                 {
 | |
|                     if (linea.Length < 20) continue;
 | |
|                     respuesta.TotalRegistrosLeidos++;
 | |
| 
 | |
|                     var referencia = linea.Substring(0, 15).Trim();
 | |
|                     var estadoProceso = linea.Substring(15, 2).Trim();
 | |
|                     var motivoRechazo = linea.Substring(17, 3).Trim();
 | |
|                     if (!int.TryParse(referencia.Replace("SUSC-", ""), out int idFactura))
 | |
|                     {
 | |
|                         respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: No se pudo extraer un ID de factura válido de la referencia '{referencia}'.");
 | |
|                         continue;
 | |
|                     }
 | |
| 
 | |
|                     var factura = await _facturaRepository.GetByIdAsync(idFactura);
 | |
|                     if (factura == null)
 | |
|                     {
 | |
|                         respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: La factura con ID {idFactura} no fue encontrada en el sistema.");
 | |
|                         continue;
 | |
|                     }
 | |
| 
 | |
|                     var nuevoPago = new Pago
 | |
|                     {
 | |
|                         IdFactura = idFactura,
 | |
|                         FechaPago = DateTime.Now.Date,
 | |
|                         IdFormaPago = 1, // Se asume una forma de pago para el débito.
 | |
|                         Monto = factura.ImporteFinal,
 | |
|                         IdUsuarioRegistro = idUsuario,
 | |
|                         Referencia = $"Lote {factura.IdLoteDebito} - Banco"
 | |
|                     };
 | |
| 
 | |
|                     if (estadoProceso == "AP")
 | |
|                     {
 | |
|                         nuevoPago.Estado = "Aprobado";
 | |
|                         await _pagoRepository.CreateAsync(nuevoPago, transaction);
 | |
|                         await _facturaRepository.UpdateEstadoPagoAsync(idFactura, "Pagada", transaction);
 | |
|                         respuesta.PagosAprobados++;
 | |
|                     }
 | |
|                     else
 | |
|                     {
 | |
|                         nuevoPago.Estado = "Rechazado";
 | |
|                         await _pagoRepository.CreateAsync(nuevoPago, transaction);
 | |
|                         await _facturaRepository.UpdateEstadoYMotivoAsync(idFactura, "Rechazada", motivoRechazo, transaction);
 | |
|                         respuesta.PagosRechazados++;
 | |
|                     }
 | |
|                 }
 | |
| 
 | |
|                 transaction.Commit();
 | |
|                 respuesta.MensajeResumen = $"Archivo procesado. Leídos: {respuesta.TotalRegistrosLeidos}, Aprobados: {respuesta.PagosAprobados}, Rechazados: {respuesta.PagosRechazados}.";
 | |
|                 _logger.LogInformation(respuesta.MensajeResumen);
 | |
|             }
 | |
|             catch (Exception ex)
 | |
|             {
 | |
|                 try { transaction.Rollback(); } catch { }
 | |
|                 _logger.LogError(ex, "Error crítico al procesar archivo de respuesta de débito.");
 | |
|                 respuesta.Errores.Add($"Error fatal en el procesamiento: {ex.Message}");
 | |
|             }
 | |
| 
 | |
|             return respuesta;
 | |
|         }
 | |
|     }
 | |
| } |