433 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
		
		
			
		
	
	
			433 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
|  | using Elecciones.Database; | ||
|  | using Elecciones.Database.Entities; | ||
|  | using Elecciones.Infrastructure.Services; | ||
|  | using Microsoft.EntityFrameworkCore; | ||
|  | 
 | ||
|  | namespace Elecciones.Worker; | ||
|  | 
 | ||
|  | public class LowPriorityDataWorker : BackgroundService | ||
|  | { | ||
|  |   private readonly ILogger<LowPriorityDataWorker> _logger; | ||
|  |   private readonly SharedTokenService _tokenService; | ||
|  |   private readonly IServiceProvider _serviceProvider; | ||
|  |   private readonly IElectoralApiService _apiService; | ||
|  | 
 | ||
|  |   public LowPriorityDataWorker( | ||
|  |       ILogger<LowPriorityDataWorker> logger, | ||
|  |       SharedTokenService tokenService, | ||
|  |       IServiceProvider serviceProvider, | ||
|  |       IElectoralApiService apiService) | ||
|  |   { | ||
|  |     _logger = logger; | ||
|  |     _tokenService = tokenService; | ||
|  |     _serviceProvider = serviceProvider; | ||
|  |     _apiService = apiService; | ||
|  |   } | ||
|  | 
 | ||
|  |   protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||
|  |   { | ||
|  |     _logger.LogInformation("Worker de Baja Prioridad iniciado."); | ||
|  | 
 | ||
|  |     await SincronizarCatalogosMaestrosAsync(stoppingToken); | ||
|  | 
 | ||
|  |     while (!stoppingToken.IsCancellationRequested) | ||
|  |     { | ||
|  |       _logger.LogInformation("--- Iniciando Ciclo de Datos de Baja Prioridad ---"); | ||
|  | 
 | ||
|  |       var authToken = await _tokenService.GetValidAuthTokenAsync(stoppingToken); | ||
|  |       if (string.IsNullOrEmpty(authToken)) | ||
|  |       { | ||
|  |         _logger.LogError("Ciclo de Baja Prioridad: No se pudo obtener token. Reintentando en 1 minuto."); | ||
|  |         await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); | ||
|  |         continue; | ||
|  |       } | ||
|  | 
 | ||
|  |       await SondearProyeccionBancasAsync(authToken, stoppingToken); | ||
|  |       await SondearNuevosTelegramasAsync(authToken, stoppingToken); | ||
|  | 
 | ||
|  |       _logger.LogInformation("--- Ciclo de Datos de Baja Prioridad completado. Esperando 5 minutos. ---"); | ||
|  |       try | ||
|  |       { | ||
|  |         await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); | ||
|  |       } | ||
|  |       catch (TaskCanceledException) | ||
|  |       { | ||
|  |         break; | ||
|  |       } | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /// <summary> | ||
|  |   /// Descarga y sincroniza los catálogos base (Categorías, Ámbitos, Agrupaciones) | ||
|  |   /// desde la API a la base de datos local. Se ejecuta una sola vez al iniciar el worker. | ||
|  |   /// Utiliza una estrategia de guardado en lotes para manejar grandes volúmenes de datos | ||
|  |   /// sin sobrecargar la base de datos. | ||
|  |   /// </summary> | ||
|  |   /// <param name="stoppingToken">El token de cancelación para detener la operación.</param> | ||
|  |   private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingToken) | ||
|  |   { | ||
|  |     try | ||
|  |     { | ||
|  |       _logger.LogInformation("Iniciando sincronización de catálogos maestros..."); | ||
|  | 
 | ||
|  |       // --- CORRECCIÓN: Usar el _tokenService inyectado --- | ||
|  |       var authToken = await _tokenService.GetValidAuthTokenAsync(stoppingToken); | ||
|  | 
 | ||
|  |       if (string.IsNullOrEmpty(authToken) || stoppingToken.IsCancellationRequested) | ||
|  |       { | ||
|  |         _logger.LogError("No se pudo obtener token para la sincronización de catálogos. La operación se cancela."); | ||
|  |         return; | ||
|  |       } | ||
|  | 
 | ||
|  |       // Creamos un scope de servicios para obtener una instancia fresca de DbContext. | ||
|  |       using var scope = _serviceProvider.CreateScope(); | ||
|  |       var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>(); | ||
|  | 
 | ||
|  |       // PASO 2: Sincronizar las categorías electorales. | ||
|  |       // Es un catálogo pequeño y es la base para las siguientes consultas. | ||
|  |       var categoriasApi = await _apiService.GetCategoriasAsync(authToken); | ||
|  |       if (categoriasApi is null || !categoriasApi.Any()) | ||
|  |       { | ||
|  |         _logger.LogWarning("La API no devolvió datos para el catálogo de Categorías. La sincronización no puede continuar."); | ||
|  |         return; | ||
|  |       } | ||
|  | 
 | ||
|  |       var distinctCategorias = categoriasApi.GroupBy(c => c.CategoriaId).Select(g => g.First()).OrderBy(c => c.Orden).ToList(); | ||
|  |       _logger.LogInformation("Se procesarán {count} categorías electorales.", distinctCategorias.Count); | ||
|  | 
 | ||
|  |       var categoriasEnDb = await dbContext.CategoriasElectorales.ToDictionaryAsync(c => c.Id, c => c, stoppingToken); | ||
|  |       foreach (var categoriaDto in distinctCategorias) | ||
|  |       { | ||
|  |         if (!categoriasEnDb.ContainsKey(categoriaDto.CategoriaId)) | ||
|  |         { | ||
|  |           dbContext.CategoriasElectorales.Add(new CategoriaElectoral { Id = categoriaDto.CategoriaId, Nombre = categoriaDto.Nombre, Orden = categoriaDto.Orden }); | ||
|  |         } | ||
|  |       } | ||
|  |       // Guardamos las categorías primero para asegurar su existencia. | ||
|  |       await dbContext.SaveChangesAsync(stoppingToken); | ||
|  | 
 | ||
|  |       // PASO 3: Cargar los catálogos existentes en memoria para una comparación eficiente. | ||
|  |       // Esto evita hacer miles de consultas a la BD dentro de un bucle. | ||
|  | 
 | ||
|  |       // Para los ámbitos, creamos una clave única robusta que funciona incluso con campos nulos. | ||
|  |       var ambitosEnDb = new Dictionary<string, AmbitoGeografico>(); | ||
|  |       var todosLosAmbitos = await dbContext.AmbitosGeograficos.ToListAsync(stoppingToken); | ||
|  |       foreach (var ambito in todosLosAmbitos) | ||
|  |       { | ||
|  |         string clave = $"{ambito.NivelId}|{ambito.DistritoId}|{ambito.SeccionProvincialId}|{ambito.SeccionId}|{ambito.MunicipioId}|{ambito.CircuitoId}|{ambito.EstablecimientoId}|{ambito.MesaId}"; | ||
|  |         if (!ambitosEnDb.ContainsKey(clave)) | ||
|  |         { | ||
|  |           ambitosEnDb.Add(clave, ambito); | ||
|  |         } | ||
|  |       } | ||
|  | 
 | ||
|  |       var agrupacionesEnDb = await dbContext.AgrupacionesPoliticas.ToDictionaryAsync(a => a.Id, a => a, stoppingToken); | ||
|  | 
 | ||
|  |       // Variable para llevar la cuenta del total de registros insertados. | ||
|  |       int totalCambiosGuardados = 0; | ||
|  | 
 | ||
|  |       // PASO 4: Iterar sobre cada categoría para sincronizar sus ámbitos y agrupaciones. | ||
|  |       foreach (var categoria in distinctCategorias) | ||
|  |       { | ||
|  |         if (stoppingToken.IsCancellationRequested) break; | ||
|  |         _logger.LogInformation("--- Sincronizando datos para la categoría: {Nombre} (ID: {Id}) ---", categoria.Nombre, categoria.CategoriaId); | ||
|  | 
 | ||
|  |         var catalogoDto = await _apiService.GetCatalogoAmbitosAsync(authToken, categoria.CategoriaId); | ||
|  |         if (catalogoDto != null) | ||
|  |         { | ||
|  |           // 4.1 - Procesar y añadir ÁMBITOS nuevos al DbContext | ||
|  |           foreach (var ambitoDto in catalogoDto.Ambitos) | ||
|  |           { | ||
|  |             string claveUnica = $"{ambitoDto.NivelId}|{ambitoDto.CodigoAmbitos.DistritoId}|{ambitoDto.CodigoAmbitos.SeccionProvincialId}|{ambitoDto.CodigoAmbitos.SeccionId}|{ambitoDto.CodigoAmbitos.MunicipioId}|{ambitoDto.CodigoAmbitos.CircuitoId}|{ambitoDto.CodigoAmbitos.EstablecimientoId}|{ambitoDto.CodigoAmbitos.MesaId}"; | ||
|  | 
 | ||
|  |             if (!ambitosEnDb.ContainsKey(claveUnica)) | ||
|  |             { | ||
|  |               var nuevoAmbito = new AmbitoGeografico | ||
|  |               { | ||
|  |                 Nombre = ambitoDto.Nombre, | ||
|  |                 NivelId = ambitoDto.NivelId, | ||
|  |                 DistritoId = ambitoDto.CodigoAmbitos.DistritoId, | ||
|  |                 SeccionProvincialId = ambitoDto.CodigoAmbitos.SeccionProvincialId, | ||
|  |                 SeccionId = ambitoDto.CodigoAmbitos.SeccionId, | ||
|  |                 MunicipioId = ambitoDto.CodigoAmbitos.MunicipioId, | ||
|  |                 CircuitoId = ambitoDto.CodigoAmbitos.CircuitoId, | ||
|  |                 EstablecimientoId = ambitoDto.CodigoAmbitos.EstablecimientoId, | ||
|  |                 MesaId = ambitoDto.CodigoAmbitos.MesaId, | ||
|  |               }; | ||
|  |               dbContext.AmbitosGeograficos.Add(nuevoAmbito); | ||
|  |               ambitosEnDb.Add(claveUnica, nuevoAmbito); // Añadir también al diccionario en memoria | ||
|  |             } | ||
|  |           } | ||
|  | 
 | ||
|  |           // 4.2 - Procesar y añadir AGRUPACIONES nuevas al DbContext | ||
|  |           var provincia = catalogoDto.Ambitos.FirstOrDefault(a => a.NivelId == 10); | ||
|  |           if (provincia != null && !string.IsNullOrEmpty(provincia.CodigoAmbitos.DistritoId)) | ||
|  |           { | ||
|  |             // Usamos un try-catch porque no todas las categorías tienen agrupaciones a nivel provincial. | ||
|  |             try | ||
|  |             { | ||
|  |               var agrupacionesApi = await _apiService.GetAgrupacionesAsync(authToken, provincia.CodigoAmbitos.DistritoId, categoria.CategoriaId); | ||
|  |               if (agrupacionesApi != null && agrupacionesApi.Any()) | ||
|  |               { | ||
|  |                 foreach (var agrupacionDto in agrupacionesApi) | ||
|  |                 { | ||
|  |                   if (!agrupacionesEnDb.ContainsKey(agrupacionDto.IdAgrupacion)) | ||
|  |                   { | ||
|  |                     var nuevaAgrupacion = new AgrupacionPolitica | ||
|  |                     { | ||
|  |                       Id = agrupacionDto.IdAgrupacion, | ||
|  |                       IdTelegrama = agrupacionDto.IdAgrupacionTelegrama, | ||
|  |                       Nombre = agrupacionDto.NombreAgrupacion | ||
|  |                     }; | ||
|  |                     dbContext.AgrupacionesPoliticas.Add(nuevaAgrupacion); | ||
|  |                     agrupacionesEnDb.Add(nuevaAgrupacion.Id, nuevaAgrupacion); | ||
|  |                   } | ||
|  |                 } | ||
|  |               } | ||
|  |             } | ||
|  |             catch (Exception ex) | ||
|  |             { | ||
|  |               _logger.LogWarning(ex, "No se pudieron obtener agrupaciones para la categoría '{catNombre}' ({catId}).", categoria.Nombre, categoria.CategoriaId); | ||
|  |             } | ||
|  |           } | ||
|  |         } | ||
|  | 
 | ||
|  |         // Después de procesar todos los ámbitos y agrupaciones de UNA categoría, guardamos los cambios. | ||
|  |         // Esto divide la inserción masiva de ~50,000 registros en 3 transacciones más pequeñas, | ||
|  |         // evitando timeouts y fallos en la base de datos. | ||
|  |         if (dbContext.ChangeTracker.HasChanges()) | ||
|  |         { | ||
|  |           int cambiosEnLote = await dbContext.SaveChangesAsync(stoppingToken); | ||
|  |           totalCambiosGuardados += cambiosEnLote; | ||
|  |           _logger.LogInformation("Guardados {count} registros de catálogo para la categoría '{catNombre}'.", cambiosEnLote, categoria.Nombre); | ||
|  |         } | ||
|  |       } | ||
|  | 
 | ||
|  |       // Ya no hay un SaveChangesAsync() gigante aquí. | ||
|  |       _logger.LogInformation("{count} nuevos registros de catálogo han sido guardados en total.", totalCambiosGuardados); | ||
|  |       _logger.LogInformation("Sincronización de catálogos maestros finalizada."); | ||
|  |     } | ||
|  |     catch (Exception ex) | ||
|  |     { | ||
|  |       _logger.LogError(ex, "Ocurrió un error CRÍTICO durante la sincronización de catálogos."); | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  | 
 | ||
|  | 
 | ||
|  |   /// <summary> | ||
|  |   /// Sondea la proyección de bancas. Este método ahora es más completo: | ||
|  |   /// 1. Consulta el reparto de bancas a nivel PROVINCIAL para cada categoría. | ||
|  |   /// 2. Consulta el reparto de bancas desglosado por SECCIÓN ELECTORAL para cada categoría. | ||
|  |   /// </summary> | ||
|  |   /// <summary> | ||
|  |   /// Sondea la proyección de bancas a nivel Provincial y por Sección Electoral. | ||
|  |   /// Esta versión recolecta todos los datos disponibles y los guarda en una única transacción. | ||
|  |   /// </summary> | ||
|  |   private async Task SondearProyeccionBancasAsync(string authToken, CancellationToken stoppingToken) | ||
|  |   { | ||
|  |     try | ||
|  |     { | ||
|  |       using var scope = _serviceProvider.CreateScope(); | ||
|  |       var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>(); | ||
|  | 
 | ||
|  |       var categoriasDeBancas = await dbContext.CategoriasElectorales | ||
|  |           .AsNoTracking() | ||
|  |           .Where(c => c.Nombre.Contains("SENADORES") || c.Nombre.Contains("DIPUTADOS")) | ||
|  |           .ToListAsync(stoppingToken); | ||
|  | 
 | ||
|  |       var provincia = await dbContext.AmbitosGeograficos | ||
|  |           .AsNoTracking() | ||
|  |           .FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken); | ||
|  | 
 | ||
|  |       var seccionesElectorales = await dbContext.AmbitosGeograficos | ||
|  |           .AsNoTracking() | ||
|  |           .Where(a => a.NivelId == 20 && a.DistritoId != null && a.SeccionProvincialId != null) | ||
|  |           .ToListAsync(stoppingToken); | ||
|  | 
 | ||
|  |       if (!categoriasDeBancas.Any() || provincia == null) | ||
|  |       { | ||
|  |         _logger.LogWarning("No se encontraron categorías de bancas o el ámbito provincial en la BD. Omitiendo sondeo."); | ||
|  |         return; | ||
|  |       } | ||
|  | 
 | ||
|  |       _logger.LogInformation("Iniciando sondeo de Bancas a nivel Provincial y para {count} Secciones Electorales...", seccionesElectorales.Count); | ||
|  | 
 | ||
|  |       // Creamos una lista para recolectar todas las proyecciones que encontremos. | ||
|  |       var nuevasProyecciones = new List<ProyeccionBanca>(); | ||
|  | 
 | ||
|  |       // 1. Bucle para el nivel Provincial | ||
|  |       foreach (var categoria in categoriasDeBancas) | ||
|  |       { | ||
|  |         if (stoppingToken.IsCancellationRequested) break; | ||
|  |         var repartoBancas = await _apiService.GetBancasAsync(authToken, provincia.DistritoId!, null, categoria.Id); | ||
|  | 
 | ||
|  |         // Si la lista de bancas no es nula (incluso si está vacía), la procesamos. | ||
|  |         if (repartoBancas?.RepartoBancas != null) | ||
|  |         { | ||
|  |           foreach (var banca in repartoBancas.RepartoBancas) | ||
|  |           { | ||
|  |             nuevasProyecciones.Add(new ProyeccionBanca | ||
|  |             { | ||
|  |               AmbitoGeograficoId = provincia.Id, | ||
|  |               AgrupacionPoliticaId = banca.IdAgrupacion, | ||
|  |               NroBancas = banca.NroBancas | ||
|  |             }); | ||
|  |           } | ||
|  |         } | ||
|  |       } | ||
|  | 
 | ||
|  |       // 2. Bucle para el nivel de Sección Electoral | ||
|  |       foreach (var seccion in seccionesElectorales) | ||
|  |       { | ||
|  |         if (stoppingToken.IsCancellationRequested) break; | ||
|  |         foreach (var categoria in categoriasDeBancas) | ||
|  |         { | ||
|  |           if (stoppingToken.IsCancellationRequested) break; | ||
|  |           var repartoBancas = await _apiService.GetBancasAsync(authToken, seccion.DistritoId!, seccion.SeccionProvincialId!, categoria.Id); | ||
|  | 
 | ||
|  |           if (repartoBancas?.RepartoBancas != null) | ||
|  |           { | ||
|  |             foreach (var banca in repartoBancas.RepartoBancas) | ||
|  |             { | ||
|  |               nuevasProyecciones.Add(new ProyeccionBanca | ||
|  |               { | ||
|  |                 AmbitoGeograficoId = seccion.Id, | ||
|  |                 AgrupacionPoliticaId = banca.IdAgrupacion, | ||
|  |                 NroBancas = banca.NroBancas | ||
|  |               }); | ||
|  |             } | ||
|  |           } | ||
|  |         } | ||
|  |       } | ||
|  | 
 | ||
|  |       // 3. Guardado Final | ||
|  |       // Ahora la condición es simple: si nuestra lista recolectora tiene CUALQUIER COSA, actualizamos la BD. | ||
|  |       if (nuevasProyecciones.Any()) | ||
|  |       { | ||
|  |         _logger.LogInformation("Se recibieron {count} registros de proyección de bancas. Actualizando la tabla...", nuevasProyecciones.Count); | ||
|  | 
 | ||
|  |         await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken); | ||
|  | 
 | ||
|  |         await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ProyeccionesBancas", stoppingToken); | ||
|  |         await dbContext.ProyeccionesBancas.AddRangeAsync(nuevasProyecciones, stoppingToken); | ||
|  |         await dbContext.SaveChangesAsync(stoppingToken); | ||
|  |         await transaction.CommitAsync(stoppingToken); | ||
|  | 
 | ||
|  |         _logger.LogInformation("Sondeo de Bancas completado. La tabla de proyecciones ha sido actualizada."); | ||
|  |       } | ||
|  |       else | ||
|  |       { | ||
|  |         // Si después de todas las llamadas, la lista sigue vacía, no hacemos nada. | ||
|  |         _logger.LogInformation("Sondeo de Bancas completado. No se encontraron datos de proyección, la tabla no fue modificada."); | ||
|  |       } | ||
|  |     } | ||
|  |     catch (Exception ex) | ||
|  |     { | ||
|  |       _logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Bancas."); | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /// <summary> | ||
|  |   /// Busca y descarga nuevos telegramas de forma masiva y concurrente. | ||
|  |   /// Este método crea una lista de todas las combinaciones de Partido/Categoría, | ||
|  |   /// las consulta a la API con un grado de paralelismo controlado, y cada tarea concurrente | ||
|  |   /// maneja su propia lógica de descarga y guardado en la base de datos. | ||
|  |   /// </summary> | ||
|  |   /// <param name="authToken">El token de autenticación válido para la sesión.</param> | ||
|  |   /// <param name="stoppingToken">El token de cancelación para detener la operación.</param> | ||
|  |   private async Task SondearNuevosTelegramasAsync(string authToken, CancellationToken stoppingToken) | ||
|  |   { | ||
|  |     try | ||
|  |     { | ||
|  |       _logger.LogInformation("--- Iniciando sondeo de Nuevos Telegramas (modo de bajo perfil) ---"); | ||
|  | 
 | ||
|  |       using var scope = _serviceProvider.CreateScope(); | ||
|  |       var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>(); | ||
|  | 
 | ||
|  |       var partidos = await dbContext.AmbitosGeograficos | ||
|  |           .AsNoTracking() | ||
|  |           .Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null) | ||
|  |           .ToListAsync(stoppingToken); | ||
|  | 
 | ||
|  |       var categorias = await dbContext.CategoriasElectorales | ||
|  |           .AsNoTracking() | ||
|  |           .ToListAsync(stoppingToken); | ||
|  | 
 | ||
|  |       if (!partidos.Any() || !categorias.Any()) return; | ||
|  | 
 | ||
|  |       // --- LÓGICA DE GOTEO LENTO --- | ||
|  |       // Procesamos una combinación (partido/categoría) a la vez. | ||
|  |       foreach (var partido in partidos) | ||
|  |       { | ||
|  |         foreach (var categoria in categorias) | ||
|  |         { | ||
|  |           // Si la aplicación se apaga, salimos inmediatamente. | ||
|  |           if (stoppingToken.IsCancellationRequested) return; | ||
|  | 
 | ||
|  |           // Obtenemos la lista de IDs. | ||
|  |           var listaTelegramasApi = await _apiService.GetTelegramasTotalizadosAsync(authToken, partido.DistritoId!, partido.SeccionId!, categoria.Id); | ||
|  | 
 | ||
|  |           if (listaTelegramasApi is { Count: > 0 }) | ||
|  |           { | ||
|  |             // Usamos un DbContext propio para este bloque para asegurar que los cambios se guarden. | ||
|  |             using var innerScope = _serviceProvider.CreateScope(); | ||
|  |             var innerDbContext = innerScope.ServiceProvider.GetRequiredService<EleccionesDbContext>(); | ||
|  | 
 | ||
|  |             var idsYaEnDb = await innerDbContext.Telegramas | ||
|  |                 .Where(t => listaTelegramasApi.Contains(t.Id)) | ||
|  |                 .Select(t => t.Id) | ||
|  |                 .ToListAsync(stoppingToken); | ||
|  | 
 | ||
|  |             var nuevosTelegramasIds = listaTelegramasApi.Except(idsYaEnDb).ToList(); | ||
|  | 
 | ||
|  |             if (nuevosTelegramasIds.Any()) | ||
|  |             { | ||
|  |               _logger.LogInformation("Se encontraron {count} telegramas nuevos en '{partido}' para '{cat}'. Descargando...", nuevosTelegramasIds.Count, partido.Nombre, categoria.Nombre); | ||
|  | 
 | ||
|  |               // Descargamos los archivos de uno en uno, con una pausa entre cada uno. | ||
|  |               foreach (var mesaId in nuevosTelegramasIds) | ||
|  |               { | ||
|  |                 if (stoppingToken.IsCancellationRequested) return; | ||
|  | 
 | ||
|  |                 var telegramaFile = await _apiService.GetTelegramaFileAsync(authToken, mesaId); | ||
|  |                 if (telegramaFile != null) | ||
|  |                 { | ||
|  |                   var nuevoTelegrama = new Telegrama | ||
|  |                   { | ||
|  |                     Id = telegramaFile.NombreArchivo, | ||
|  |                     AmbitoGeograficoId = partido.Id, | ||
|  |                     ContenidoBase64 = telegramaFile.Imagen, | ||
|  |                     FechaEscaneo = DateTime.Parse(telegramaFile.FechaEscaneo).ToUniversalTime(), | ||
|  |                     FechaTotalizacion = DateTime.Parse(telegramaFile.FechaTotalizacion).ToUniversalTime() | ||
|  |                   }; | ||
|  |                   await innerDbContext.Telegramas.AddAsync(nuevoTelegrama, stoppingToken); | ||
|  |                 } | ||
|  |                 // PAUSA DELIBERADA: Esperamos un poco para no parecer un bot. | ||
|  |                 await Task.Delay(250, stoppingToken); // 250ms de espera = 4 peticiones/segundo máximo. | ||
|  |               } | ||
|  |               await innerDbContext.SaveChangesAsync(stoppingToken); | ||
|  |             } | ||
|  |           } | ||
|  | 
 | ||
|  |           // PAUSA DELIBERADA: Esperamos un poco entre cada consulta de lista de telegramas. | ||
|  |           await Task.Delay(100, stoppingToken); | ||
|  |         } | ||
|  |       } | ||
|  |       _logger.LogInformation("Sondeo de Telegramas completado."); | ||
|  |     } | ||
|  |     catch (OperationCanceledException) | ||
|  |     { | ||
|  |       _logger.LogInformation("Sondeo de telegramas cancelado."); | ||
|  |     } | ||
|  |     catch (Exception ex) | ||
|  |     { | ||
|  |       _logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Telegramas."); | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   // Pega aquí los métodos: | ||
|  |   // - SincronizarCatalogosMaestrosAsync | ||
|  |   // - SondearProyeccionBancasAsync | ||
|  |   // - SondearNuevosTelegramasAsync (la versión con goteo lento) | ||
|  | } |