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