using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using GestorFacturas.API.Data; using GestorFacturas.API.Models; using GestorFacturas.API.Services.Interfaces; using System.Data; namespace GestorFacturas.API.Services; /// /// Servicio principal de procesamiento de facturas. /// public class ProcesadorFacturasService : IProcesadorFacturasService { private readonly ApplicationDbContext _context; private readonly ILogger _logger; private readonly IMailService _mailService; private readonly IEncryptionService _encryptionService; private readonly IConfiguration _configuration; private const int MAX_REINTENTOS = 10; private const int DELAY_SEGUNDOS = 60; public ProcesadorFacturasService( ApplicationDbContext context, ILogger logger, IMailService mailService, IEncryptionService encryptionService, IConfiguration configuration) { _context = context; _logger = logger; _mailService = mailService; _encryptionService = encryptionService; _configuration = configuration; } public async Task EjecutarProcesoAsync(DateTime fechaDesde) { // Contadores inicializados AL PRINCIPIO int copiadas = 0; int omitidas = 0; int errores = 0; // No declarar 'int procesadas = 0' porque es redundante con copiadas/omitidas var config = await _context.Configuraciones.FirstOrDefaultAsync(c => c.Id == 1); if (config == null) { await RegistrarEventoAsync("No se encontró configuración del sistema", TipoEvento.Error); return; } config.UltimaEjecucion = DateTime.Now; await _context.SaveChangesAsync(); await RegistrarEventoAsync($"Iniciando proceso de facturas desde {fechaDesde:dd/MM/yyyy}", TipoEvento.Info); try { var facturas = await ObtenerFacturasDesdeERPAsync(config, fechaDesde); if (facturas.Count == 0) { await RegistrarEventoAsync($"No se encontraron facturas desde {fechaDesde:dd/MM/yyyy}", TipoEvento.Warning); config.Estado = true; await _context.SaveChangesAsync(); return; } await RegistrarEventoAsync($"Se encontraron {facturas.Count} facturas para procesar", TipoEvento.Info); List pendientes = new(); List detallesErroresParaMail = new(); List eventosDeErrorParaActualizar = new(); // --- 1. Primer intento --- foreach (var factura in facturas) { var resultado = await ProcesarFacturaAsync(factura, config); switch (resultado) { case ResultadoProceso.Copiado: copiadas++; break; case ResultadoProceso.Omitido: omitidas++; break; case ResultadoProceso.Error: pendientes.Add(factura); break; } } // --- 2. Sistema de Reintentos --- if (pendientes.Count > 0) { await RegistrarEventoAsync($"{pendientes.Count} archivos no encontrados. Iniciando sistema de reintentos...", TipoEvento.Warning); for (int intento = 1; intento <= MAX_REINTENTOS && pendientes.Count > 0; intento++) { await Task.Delay(TimeSpan.FromSeconds(DELAY_SEGUNDOS)); var aunPendientes = new List(); foreach (var factura in pendientes) { var resultado = await ProcesarFacturaAsync(factura, config); if (resultado != ResultadoProceso.Error) { if (resultado == ResultadoProceso.Copiado) copiadas++; if (resultado == ResultadoProceso.Omitido) omitidas++; _logger.LogInformation("Archivo recuperado en reintento {intento}: {archivo}", intento, factura.NombreArchivoOrigen); } else { aunPendientes.Add(factura); } } pendientes = aunPendientes; } // --- 3. Registro de Errores Finales --- if (pendientes.Count > 0) { errores = pendientes.Count; // Aquí usamos la variable ya declarada arriba foreach (var factura in pendientes) { string msgError = $"El archivo NO EXISTE después de {MAX_REINTENTOS} intentos: {factura.NombreArchivoOrigen}"; var eventoError = new Evento { Fecha = DateTime.Now, Mensaje = msgError, Tipo = TipoEvento.Error.ToString(), Enviado = false }; _context.Eventos.Add(eventoError); eventosDeErrorParaActualizar.Add(eventoError); detallesErroresParaMail.Add(factura.NombreArchivoOrigen); } await _context.SaveChangesAsync(); } } // --- 4. Actualización de Estado General --- config.Estado = errores == 0; await _context.SaveChangesAsync(); // --- 5. Limpieza automática --- try { var fechaLimiteBorrado = DateTime.Now.AddMonths(-1); var eventosViejos = _context.Eventos.Where(e => e.Fecha < fechaLimiteBorrado); if (eventosViejos.Any()) { _context.Eventos.RemoveRange(eventosViejos); await _context.SaveChangesAsync(); } } catch { } // --- 6. Evento Final --- var mensajeFinal = $"Proceso finalizado. Nuevas: {copiadas}, Verificadas: {omitidas}, Errores: {errores}"; await RegistrarEventoAsync(mensajeFinal, errores > 0 ? TipoEvento.Warning : TipoEvento.Info); // --- 7. Envío de Mail Inteligente --- if (errores > 0 && config.AvisoMail && !string.IsNullOrEmpty(config.SMTPDestinatario)) { var historialErroresEnviados = await _context.Eventos .Where(e => e.Tipo == TipoEvento.Error.ToString() && e.Enviado == true) .Select(e => e.Mensaje) .ToListAsync(); var archivosNuevosParaNotificar = detallesErroresParaMail.Where(archivoFallido => { bool yaFueNotificado = historialErroresEnviados.Any(msgHistorico => msgHistorico.Contains(archivoFallido)); return !yaFueNotificado; }).ToList(); bool mailEnviado = false; if (archivosNuevosParaNotificar.Count > 0) { // Pasamos 'copiadas' en lugar de 'procesadas' para el mail, o la suma de ambas si prefieres mailEnviado = await EnviarNotificacionErroresAsync( config.SMTPDestinatario, copiadas + omitidas, errores, archivosNuevosParaNotificar ); if (mailEnviado) { _logger.LogInformation("Correo de alerta enviado con {count} archivos nuevos.", archivosNuevosParaNotificar.Count); } } else { _logger.LogInformation("Se omitió el envío de correo por errores repetidos."); mailEnviado = true; } if (mailEnviado && eventosDeErrorParaActualizar.Count > 0) { foreach (var evento in eventosDeErrorParaActualizar) { evento.Enviado = true; } await _context.SaveChangesAsync(); } } } catch (Exception ex) { _logger.LogError(ex, "Error crítico en el proceso de facturas"); string mensajeCritico = $"ERROR CRÍTICO DEL SISTEMA: {ex.Message}"; await RegistrarEventoAsync(mensajeCritico, TipoEvento.Error); if (config != null) { config.Estado = false; await _context.SaveChangesAsync(); if (config.AvisoMail && !string.IsNullOrEmpty(config.SMTPDestinatario)) { var listaErroresCriticos = new List { mensajeCritico }; await EnviarNotificacionErroresAsync(config.SMTPDestinatario, 0, 1, listaErroresCriticos); } } } } private async Task> ObtenerFacturasDesdeERPAsync(Configuracion config, DateTime fechaDesde) { var facturas = new List(); var connectionString = ConstruirCadenaConexion(config); try { using var conexion = new SqlConnection(connectionString); await conexion.OpenAsync(); var query = @" SELECT DISTINCT NUMERO_FACTURA, TIPO_FACTURA, CLIENTE, SUCURSAL_FACTURA, DIVISION_FACTURA, FECHACOMPLETA_FACTURA, NRO_CAI FROM VISTA_FACTURACION_ELDIA WHERE SUCURSAL_FACTURA = '70' AND NRO_CAI IS NOT NULL AND NRO_CAI != '' AND FECHACOMPLETA_FACTURA >= @FechaDesde AND CONVERT(DATE, FECHACOMPLETA_FACTURA) <= CONVERT(DATE, GETDATE()) ORDER BY FECHACOMPLETA_FACTURA DESC"; using var comando = new SqlCommand(query, conexion); comando.Parameters.AddWithValue("@FechaDesde", fechaDesde); using var reader = await comando.ExecuteReaderAsync(); while (await reader.ReadAsync()) { var factura = new FacturaParaProcesar { NumeroFactura = (reader["NUMERO_FACTURA"].ToString() ?? "").Trim().PadLeft(10, '0'), TipoFactura = (reader["TIPO_FACTURA"].ToString() ?? "").Trim(), Cliente = (reader["CLIENTE"].ToString() ?? "").Trim().PadLeft(6, '0'), Sucursal = (reader["SUCURSAL_FACTURA"].ToString() ?? "").Trim().PadLeft(4, '0'), CodigoEmpresa = (reader["DIVISION_FACTURA"].ToString() ?? "").Trim().PadLeft(4, '0'), FechaFactura = Convert.ToDateTime(reader["FECHACOMPLETA_FACTURA"]) }; factura.NombreEmpresa = MapearNombreEmpresa(factura.CodigoEmpresa); factura.NombreArchivoOrigen = ConstruirNombreArchivoOrigen(factura); factura.NombreArchivoDestino = ConstruirNombreArchivoDestino(factura); factura.CarpetaDestino = ConstruirRutaCarpetaDestino(factura); facturas.Add(factura); } } catch (Exception ex) { await RegistrarEventoAsync($"Error al conectar con el ERP: {ex.Message}", TipoEvento.Error); throw; } return facturas; } private async Task ProcesarFacturaAsync(FacturaParaProcesar factura, Configuracion config) { try { string rutaBaseOrigen = config.RutaFacturas; string rutaBaseDestino = config.RutaDestino; string rutaOrigen = Path.Combine(rutaBaseOrigen, factura.NombreArchivoOrigen); string carpetaDestinoFinal = Path.Combine(rutaBaseDestino, factura.CarpetaDestino); // Si no existe origen, es un error (para reintento) if (!File.Exists(rutaOrigen)) return ResultadoProceso.Error; if (!Directory.Exists(carpetaDestinoFinal)) { Directory.CreateDirectory(carpetaDestinoFinal); } string rutaDestinoCompleta = Path.Combine(carpetaDestinoFinal, factura.NombreArchivoDestino); FileInfo infoOrigen = new FileInfo(rutaOrigen); FileInfo infoDestino = new FileInfo(rutaDestinoCompleta); if (infoDestino.Exists) { // Si ya existe y es igual, lo OMITIMOS if (infoDestino.Length == infoOrigen.Length) { return ResultadoProceso.Omitido; } } // Si llegamos acá, copiamos File.Copy(rutaOrigen, rutaDestinoCompleta, overwrite: true); return ResultadoProceso.Copiado; } catch (Exception ex) { _logger.LogDebug(ex, "Fallo al copiar {archivo}", factura.NombreArchivoOrigen); return ResultadoProceso.Error; } } // --- MÉTODOS DE MAPEO (CONFIGURABLES) --- private string MapearNombreEmpresa(string codigoEmpresa) { var nombre = _configuration[$"EmpresasMapping:{codigoEmpresa}"]; return string.IsNullOrEmpty(nombre) ? "DESCONOCIDA" : nombre; } private string AjustarTipoFactura(string tipoOriginal) { if (string.IsNullOrEmpty(tipoOriginal)) return tipoOriginal; // 1. Buscamos mapeo en appsettings.json var tipoMapeado = _configuration[$"FacturaTiposMapping:{tipoOriginal}"]; if (!string.IsNullOrEmpty(tipoMapeado)) { return tipoMapeado; } // 2. Fallback Legacy si no está mapeado return tipoOriginal[^1].ToString(); } // --- CONSTRUCCIÓN DE NOMBRES --- private string ConstruirNombreArchivoOrigen(FacturaParaProcesar factura) { return $"{factura.Cliente}-{factura.CodigoEmpresa}-{factura.Sucursal}-{factura.TipoFactura}-{factura.NumeroFactura}.pdf"; } private string ConstruirNombreArchivoDestino(FacturaParaProcesar factura) { // El archivo final conserva el Tipo ORIGINAL return $"{factura.Cliente}-{factura.CodigoEmpresa}-{factura.Sucursal}-{factura.TipoFactura}-{factura.NumeroFactura}.pdf"; } private string ConstruirRutaCarpetaDestino(FacturaParaProcesar factura) { // La carpeta usa el Tipo AJUSTADO string tipoAjustado = AjustarTipoFactura(factura.TipoFactura); string anioMes = factura.FechaFactura.ToString("yyyy-MM"); string nombreCarpetaFactura = $"{factura.Cliente}-{factura.CodigoEmpresa}-{factura.Sucursal}-{tipoAjustado}-{factura.NumeroFactura}"; return Path.Combine(factura.NombreEmpresa, anioMes, nombreCarpetaFactura); } // --- UTILIDADES --- private string ConstruirCadenaConexion(Configuracion config) { var builder = new SqlConnectionStringBuilder { DataSource = config.DBServidor, InitialCatalog = config.DBNombre, IntegratedSecurity = config.DBTrusted, TrustServerCertificate = true, ConnectTimeout = 30 }; if (!config.DBTrusted) { builder.UserID = _encryptionService.Decrypt(config.DBUsuario ?? ""); builder.Password = _encryptionService.Decrypt(config.DBClave ?? ""); } return builder.ConnectionString; } private async Task RegistrarEventoAsync(string mensaje, TipoEvento tipo) { try { var evento = new Evento { Fecha = DateTime.Now, Mensaje = mensaje, Tipo = tipo.ToString(), Enviado = false }; _context.Eventos.Add(evento); await _context.SaveChangesAsync(); } catch (Exception ex) { _logger.LogError(ex, "Error al registrar evento"); } } private async Task EnviarNotificacionErroresAsync(string destinatario, int procesadas, int errores, List detalles) { try { var asunto = errores == 1 && detalles.Count > 0 && detalles[0].StartsWith("ERROR CRÍTICO") ? "ALERTA CRÍTICA: Fallo del Sistema Gestor de Facturas" : "Alerta: Errores en Procesamiento de Facturas"; string listaArchivosHtml = ""; if (detalles != null && detalles.Count > 0) { listaArchivosHtml = "

Detalle de Errores:

    "; foreach (var archivo in detalles) { listaArchivosHtml += $"
  • {archivo}
  • "; } listaArchivosHtml += "
"; } var cuerpo = $@"

{asunto}

Fecha de Ejecución: {DateTime.Now:dd/MM/yyyy HH:mm:ss}

Facturas procesadas exitosamente: {procesadas}

Facturas con error: {errores}

{listaArchivosHtml}

Sistema Gestor de Facturas El Día.

"; return await _mailService.EnviarCorreoAsync(destinatario, asunto, cuerpo, true); } catch (Exception ex) { _logger.LogError(ex, "Error al preparar notificación de errores"); return false; } } } /// /// Clase auxiliar para representar una factura a procesar /// public class FacturaParaProcesar { public string NumeroFactura { get; set; } = string.Empty; public string TipoFactura { get; set; } = string.Empty; public string Cliente { get; set; } = string.Empty; public string Sucursal { get; set; } = string.Empty; public string CodigoEmpresa { get; set; } = string.Empty; public string NombreEmpresa { get; set; } = string.Empty; public DateTime FechaFactura { get; set; } public string NombreArchivoOrigen { get; set; } = string.Empty; public string NombreArchivoDestino { get; set; } = string.Empty; public string CarpetaDestino { get; set; } = string.Empty; } public enum ResultadoProceso { Copiado, // Era nuevo y se copió Omitido, // Ya existía y estaba bien Error // No se encontró o falló }