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) { 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; } // Actualizamos la fecha de ejecución 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); int procesadas = 0; int errores = 0; List pendientes = new(); List detallesErroresParaMail = new(); // Lista para rastrear las entidades de Evento y actualizar su flag 'Enviado' luego List eventosDeErrorParaActualizar = new(); // --- 1. Primer intento --- foreach (var factura in facturas) { bool exito = await ProcesarFacturaAsync(factura, config); if (exito) procesadas++; else pendientes.Add(factura); } // --- 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) { bool exito = await ProcesarFacturaAsync(factura, config); if (exito) { procesadas++; _logger.LogInformation("Archivo encontrado 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; 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); // Guardamos referencias 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. Procesadas: {procesadas}, Errores: {errores}"; await RegistrarEventoAsync(mensajeFinal, errores > 0 ? TipoEvento.Warning : TipoEvento.Info); // --- 7. Envío de Mail Inteligente (Solo 1 vez por archivo) --- if (errores > 0 && config.AvisoMail && !string.IsNullOrEmpty(config.SMTPDestinatario)) { // 1. Buscamos en la historia de la DB si estos archivos ya fueron reportados previamente. // Buscamos en TODOS los logs disponibles (que suelen ser los últimos 30 días según la limpieza). // Filtramos por Tipo Error y Enviado=true. var historialErroresEnviados = await _context.Eventos .Where(e => e.Tipo == TipoEvento.Error.ToString() && e.Enviado == true) .Select(e => e.Mensaje) .ToListAsync(); // 2. Filtramos la lista actual: // Solo queremos los archivos que NO aparezcan en ningún mensaje del historial. var archivosNuevosParaNotificar = detallesErroresParaMail.Where(archivoFallido => { // El mensaje en BD es: "El archivo NO EXISTE...: nombre_archivo.pdf" // Chequeamos si el nombre del archivo está contenido en algún mensaje viejo. bool yaFueNotificado = historialErroresEnviados.Any(msgHistorico => msgHistorico.Contains(archivoFallido)); return !yaFueNotificado; }).ToList(); // 3. Decidir si enviar mail bool mailEnviado = false; if (archivosNuevosParaNotificar.Count > 0) { // Si hay archivos NUEVOS, enviamos mail SOLO con esos. // Nota: Pasamos 'errores' (total técnico) y 'archivosNuevosParaNotificar' (detalle visual) mailEnviado = await EnviarNotificacionErroresAsync( config.SMTPDestinatario, procesadas, 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: Los {count} errores ya fueron notificados anteriormente.", errores); // Simulamos que se "envió" (se gestionó) para marcar los flags en BD mailEnviado = true; } // 4. Actualizar flag en BD (CRÍTICO) // Si gestionamos la notificación correctamente (ya sea enviándola o detectando que ya estaba enviada), // marcamos los eventos actuales como Enviado=true para que pasen al historial y no se vuelvan a procesar. 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); if (!File.Exists(rutaOrigen)) return false; 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) { if (infoDestino.Length == infoOrigen.Length) return true; } File.Copy(rutaOrigen, rutaDestinoCompleta, overwrite: true); return true; } catch (Exception ex) { _logger.LogDebug(ex, "Fallo al copiar {archivo}", factura.NombreArchivoOrigen); return false; } } // --- 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; }