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;
|
|
}
|
|
}
|
|
} |