From c967da919a133dbbde28bd18fad77040d8dfcaa5 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 20 Aug 2025 16:58:18 -0300 Subject: [PATCH] =?UTF-8?q?Try=20Separaci=C3=B3n=20de=20Metodos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/SharedTokenService.cs | 64 ++ .../Elecciones.Infrastructure.AssemblyInfo.cs | 2 +- .../Elecciones.Worker/CriticalDataWorker.cs | 430 +++++++++ .../LowPriorityDataWorker.cs | 433 +++++++++ .../src/Elecciones.Worker/Program.cs | 8 +- .../src/Elecciones.Worker/Worker.cs | 848 ------------------ .../net9.0/Elecciones.Worker.AssemblyInfo.cs | 2 +- 7 files changed, 936 insertions(+), 851 deletions(-) create mode 100644 Elecciones-Web/src/Elecciones.Infrastructure/Services/SharedTokenService.cs create mode 100644 Elecciones-Web/src/Elecciones.Worker/CriticalDataWorker.cs create mode 100644 Elecciones-Web/src/Elecciones.Worker/LowPriorityDataWorker.cs delete mode 100644 Elecciones-Web/src/Elecciones.Worker/Worker.cs diff --git a/Elecciones-Web/src/Elecciones.Infrastructure/Services/SharedTokenService.cs b/Elecciones-Web/src/Elecciones.Infrastructure/Services/SharedTokenService.cs new file mode 100644 index 0000000..9b446fd --- /dev/null +++ b/Elecciones-Web/src/Elecciones.Infrastructure/Services/SharedTokenService.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Elecciones.Infrastructure.Services; + +public class SharedTokenService +{ + private readonly IElectoralApiService _apiService; + private readonly ILogger _logger; + private string? _authToken; + private DateTimeOffset _tokenExpiration = DateTimeOffset.MinValue; + + // Un SemaphoreSlim para asegurar que solo una tarea a la vez intente renovar el token. + private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); + + public SharedTokenService(IElectoralApiService apiService, ILogger logger) + { + _apiService = apiService; + _logger = logger; + } + + public async Task GetValidAuthTokenAsync(CancellationToken stoppingToken) + { + // Si el token es válido, lo devolvemos inmediatamente sin bloquear. + if (!string.IsNullOrEmpty(_authToken) && DateTimeOffset.UtcNow < _tokenExpiration.AddMinutes(-1)) + { + return _authToken; + } + + // Si el token necesita renovación, esperamos nuestro turno para intentar renovarlo. + await _tokenSemaphore.WaitAsync(stoppingToken); + try + { + // Volvemos a comprobar por si otra tarea ya lo renovó mientras esperábamos. + if (!string.IsNullOrEmpty(_authToken) && DateTimeOffset.UtcNow < _tokenExpiration.AddMinutes(-1)) + { + return _authToken; + } + + _logger.LogInformation("Token no válido o a punto de expirar. Solicitando uno nuevo..."); + var tokenResponse = await _apiService.GetAuthTokenAsync(); + + if (tokenResponse?.Data?.AccessToken != null) + { + _authToken = tokenResponse.Data.AccessToken; + _tokenExpiration = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.Data.ExpiresIn); + _logger.LogInformation("Nuevo token obtenido. Válido hasta: {expiration}", _tokenExpiration); + } + else + { + _logger.LogError("CRÍTICO: No se pudo obtener un nuevo token de autenticación."); + _authToken = null; + } + } + finally + { + _tokenSemaphore.Release(); + } + + return _authToken; + } +} \ No newline at end of file diff --git a/Elecciones-Web/src/Elecciones.Infrastructure/obj/Debug/net9.0/Elecciones.Infrastructure.AssemblyInfo.cs b/Elecciones-Web/src/Elecciones.Infrastructure/obj/Debug/net9.0/Elecciones.Infrastructure.AssemblyInfo.cs index 7c238c4..76b62f2 100644 --- a/Elecciones-Web/src/Elecciones.Infrastructure/obj/Debug/net9.0/Elecciones.Infrastructure.AssemblyInfo.cs +++ b/Elecciones-Web/src/Elecciones.Infrastructure/obj/Debug/net9.0/Elecciones.Infrastructure.AssemblyInfo.cs @@ -13,7 +13,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Infrastructure")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68dce9415e165633856e4fae9b2d71cc07b4e2ff")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+19b37f73206d043982fc77f8c2359f2598889b64")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Infrastructure")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Infrastructure")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/Elecciones-Web/src/Elecciones.Worker/CriticalDataWorker.cs b/Elecciones-Web/src/Elecciones.Worker/CriticalDataWorker.cs new file mode 100644 index 0000000..e6e3f0e --- /dev/null +++ b/Elecciones-Web/src/Elecciones.Worker/CriticalDataWorker.cs @@ -0,0 +1,430 @@ +using Elecciones.Database; +using Elecciones.Database.Entities; +using Elecciones.Infrastructure.Services; +using Microsoft.EntityFrameworkCore; +using System.Collections.Concurrent; + +namespace Elecciones.Worker; + +public class CriticalDataWorker : BackgroundService +{ + private readonly ILogger _logger; + private readonly SharedTokenService _tokenService; + private readonly IServiceProvider _serviceProvider; + private readonly IElectoralApiService _apiService; // <-- DEPENDENCIA AÑADIDA + + // Inyectamos IElectoralApiService en el constructor + public CriticalDataWorker( + ILogger logger, + SharedTokenService tokenService, + IServiceProvider serviceProvider, + IElectoralApiService apiService) // <-- PARÁMETRO AÑADIDO + { + _logger = logger; + _tokenService = tokenService; + _serviceProvider = serviceProvider; + _apiService = apiService; // <-- ASIGNACIÓN AÑADIDA + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Worker de Datos Críticos iniciado."); + + // Damos tiempo a la sincronización inicial del otro worker para que se complete. + try + { + await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); + } + catch (TaskCanceledException) { return; } // Salir si la app se apaga durante la espera inicial + + int cicloContador = 0; + while (!stoppingToken.IsCancellationRequested) + { + var cicloInicio = DateTime.UtcNow; + cicloContador++; + + _logger.LogInformation("--- Iniciando Ciclo de Datos Críticos #{ciclo} ---", cicloContador); + + var authToken = await _tokenService.GetValidAuthTokenAsync(stoppingToken); + if (string.IsNullOrEmpty(authToken)) + { + _logger.LogError("Ciclo Crítico: No se pudo obtener token. Reintentando en 30 segundos."); + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + continue; + } + + await SondearResultadosMunicipalesAsync(authToken, stoppingToken); + await SondearResumenProvincialAsync(authToken, stoppingToken); + await SondearEstadoRecuentoGeneralAsync(authToken, stoppingToken); + + var cicloFin = DateTime.UtcNow; + var duracionCiclo = cicloFin - cicloInicio; + _logger.LogInformation("--- Ciclo de Datos Críticos #{ciclo} completado en {duration:N2} segundos. ---", cicloContador, duracionCiclo.TotalSeconds); + + var tiempoDeEspera = TimeSpan.FromSeconds(30) - duracionCiclo; + if (tiempoDeEspera < TimeSpan.Zero) tiempoDeEspera = TimeSpan.Zero; + + try + { + await Task.Delay(tiempoDeEspera, stoppingToken); + } + catch (TaskCanceledException) + { + break; + } + } + } + + /// + /// Sondea los resultados electorales para todos los municipios/partidos de forma optimizada. + /// Utiliza paralelismo controlado para ejecutar múltiples peticiones a la API simultáneamente + /// sin sobrecargar la red, y luego guarda todos los resultados en la base de datos de forma masiva. + /// + /// El token de autenticación válido para la sesión. + /// El token de cancelación para detener la operación. + private async Task SondearResultadosMunicipalesAsync(string authToken, CancellationToken stoppingToken) + { + try + { + // PASO 1: Preparar el DbContext y los datos necesarios. + using var scope = _serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Obtenemos de nuestra BD local la lista de todos los partidos (NivelId=30) que necesitamos consultar. + var municipiosASondear = await dbContext.AmbitosGeograficos + .AsNoTracking() + .Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null) + .Select(a => new { a.Id, a.Nombre, a.MunicipioId, a.SeccionId, a.DistritoId }) + .ToListAsync(stoppingToken); + + if (!municipiosASondear.Any()) + { + _logger.LogWarning("No se encontraron Partidos (NivelId 30) en la BD para sondear resultados."); + return; + } + + // Obtenemos la categoría "CONCEJALES", ya que los resultados municipales aplican a esta. + var categoriaConcejales = await dbContext.CategoriasElectorales + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Nombre.Contains("CONCEJALES"), stoppingToken); + + if (categoriaConcejales == null) + { + _logger.LogWarning("No se encontró la categoría 'CONCEJALES'. Omitiendo sondeo de resultados municipales."); + return; + } + + // PASO 2: Ejecutar las consultas a la API con paralelismo controlado. + + // Definimos cuántas peticiones queremos que se ejecuten simultáneamente. + // Un valor entre 8 y 16 es generalmente seguro y ofrece una gran mejora de velocidad. + const int GRADO_DE_PARALELISMO = 3; + // Creamos un semáforo que actuará como un "control de acceso" con 10 pases libres. + var semaforo = new SemaphoreSlim(GRADO_DE_PARALELISMO); + + // Usamos un ConcurrentDictionary para almacenar los resultados. A diferencia de un Dictionary normal, + // este permite que múltiples tareas escriban en él al mismo tiempo sin conflictos. + var resultadosPorId = new ConcurrentDictionary(); + + _logger.LogInformation("Iniciando sondeo de resultados para {count} municipios con un paralelismo de {degree}...", municipiosASondear.Count, GRADO_DE_PARALELISMO); + + // Creamos una lista de tareas (Tasks), una por cada municipio a consultar. + // El método .Select() no ejecuta las tareas todavía, solo las prepara. + var tareas = municipiosASondear.Select(async municipio => + { + // Cada tarea debe "pedir permiso" al semáforo antes de ejecutarse. + // Si ya hay 10 tareas en ejecución, esta línea esperará hasta que una termine. + await semaforo.WaitAsync(stoppingToken); + try + { + // Una vez que obtiene el permiso, ejecuta la petición a la API. + var resultados = await _apiService.GetResultadosAsync( + authToken, municipio.DistritoId!, municipio.SeccionId!, null, categoriaConcejales.Id + ); + + // Si la API devuelve datos válidos... + if (resultados != null) + { + // ...los guardamos en el diccionario concurrente. + resultadosPorId[municipio.Id] = resultados; + } + } + finally + { + // ¡CRUCIAL! Liberamos el pase del semáforo, permitiendo que la siguiente + // tarea en espera pueda comenzar su ejecución. + semaforo.Release(); + // Añadir un pequeño retraso aleatorio para no parecer un robot + await Task.Delay(TimeSpan.FromMilliseconds(new Random().Next(50, 251)), stoppingToken); + } + }); + + // Ahora sí, ejecutamos todas las tareas preparadas en paralelo y esperamos a que todas terminen. + await Task.WhenAll(tareas); + + // PASO 3: Guardar los resultados en la base de datos. + // Solo procedemos si recolectamos al menos un resultado válido. + if (resultadosPorId.Any()) + { + // Llamamos a nuestro método de guardado masivo y optimizado, pasándole todos los resultados + // recolectados para que los inserte en una única y eficiente transacción. + await GuardarResultadosDeMunicipiosAsync(dbContext, resultadosPorId.ToDictionary(kv => kv.Key, kv => kv.Value), stoppingToken); + } + } + catch (Exception ex) + { + // Capturamos cualquier error inesperado en el proceso para que el worker no se detenga. + _logger.LogError(ex, "Ocurrió un error inesperado durante el sondeo de resultados municipales."); + } + } + + /// Realiza una operación "Upsert" (Update o Insert) de forma masiva y optimizada. + /// Este método es llamado por SondearResultadosMunicipalesAsync. + /// + private async Task GuardarResultadosDeMunicipiosAsync( + EleccionesDbContext dbContext, + Dictionary todosLosResultados, + CancellationToken stoppingToken) // <-- PARÁMETRO AÑADIDO + { + // Obtenemos los IDs de todos los ámbitos que vamos a actualizar. + var ambitoIds = todosLosResultados.Keys; + + // --- OPTIMIZACIÓN 1: Cargar todos los datos existentes en memoria UNA SOLA VEZ --- + var estadosRecuentoExistentes = await dbContext.EstadosRecuentos + .Where(e => ambitoIds.Contains(e.AmbitoGeograficoId)) + .ToDictionaryAsync(e => e.AmbitoGeograficoId, stoppingToken); + + var resultadosVotosExistentes = await dbContext.ResultadosVotos + .Where(rv => ambitoIds.Contains(rv.AmbitoGeograficoId)) + .GroupBy(rv => rv.AmbitoGeograficoId) + .ToDictionaryAsync(g => g.Key, g => g.ToDictionary(item => item.AgrupacionPoliticaId), stoppingToken); + + _logger.LogInformation("Procesando en memoria los resultados de {count} municipios.", todosLosResultados.Count); + + // --- OPTIMIZACIÓN 2: Procesar todo en memoria --- + foreach (var kvp in todosLosResultados) + { + var ambitoId = kvp.Key; + var resultadosDto = kvp.Value; + + // Lógica Upsert para EstadoRecuento + if (!estadosRecuentoExistentes.TryGetValue(ambitoId, out var estadoRecuento)) + { + estadoRecuento = new EstadoRecuento { AmbitoGeograficoId = ambitoId }; + dbContext.EstadosRecuentos.Add(estadoRecuento); + } + + // Mapeo completo de propiedades para EstadoRecuento + estadoRecuento.FechaTotalizacion = DateTime.Parse(resultadosDto.FechaTotalizacion).ToUniversalTime(); + estadoRecuento.MesasEsperadas = resultadosDto.EstadoRecuento.MesasEsperadas; + estadoRecuento.MesasTotalizadas = resultadosDto.EstadoRecuento.MesasTotalizadas; + estadoRecuento.MesasTotalizadasPorcentaje = resultadosDto.EstadoRecuento.MesasTotalizadasPorcentaje; + estadoRecuento.CantidadElectores = resultadosDto.EstadoRecuento.CantidadElectores; + estadoRecuento.CantidadVotantes = resultadosDto.EstadoRecuento.CantidadVotantes; + estadoRecuento.ParticipacionPorcentaje = resultadosDto.EstadoRecuento.ParticipacionPorcentaje; + + if (resultadosDto.ValoresTotalizadosOtros != null) + { + estadoRecuento.VotosEnBlanco = resultadosDto.ValoresTotalizadosOtros.VotosEnBlanco; + estadoRecuento.VotosEnBlancoPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosEnBlancoPorcentaje; + estadoRecuento.VotosNulos = resultadosDto.ValoresTotalizadosOtros.VotosNulos; + estadoRecuento.VotosNulosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosNulosPorcentaje; + estadoRecuento.VotosRecurridos = resultadosDto.ValoresTotalizadosOtros.VotosRecurridos; + estadoRecuento.VotosRecurridosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosRecurridosPorcentaje; + } + + // Lógica Upsert para ResultadosVotos + var votosDeAmbitoExistentes = resultadosVotosExistentes.GetValueOrDefault(ambitoId); + foreach (var votoPositivoDto in resultadosDto.ValoresTotalizadosPositivos) + { + ResultadoVoto? resultadoVoto = null; + if (votosDeAmbitoExistentes != null) + { + votosDeAmbitoExistentes.TryGetValue(votoPositivoDto.IdAgrupacion, out resultadoVoto); + } + + if (resultadoVoto == null) + { + resultadoVoto = new ResultadoVoto + { + AmbitoGeograficoId = ambitoId, + AgrupacionPoliticaId = votoPositivoDto.IdAgrupacion + }; + dbContext.ResultadosVotos.Add(resultadoVoto); + } + resultadoVoto.CantidadVotos = votoPositivoDto.Votos; + resultadoVoto.PorcentajeVotos = votoPositivoDto.VotosPorcentaje; + } + } + + // --- OPTIMIZACIÓN 3: Guardar todos los cambios en UNA SOLA TRANSACCIÓN --- + _logger.LogInformation("Guardando todos los cambios de resultados municipales en la base de datos..."); + // Ahora 'stoppingToken' es reconocido aquí + await dbContext.SaveChangesAsync(stoppingToken); + _logger.LogInformation("Guardado completado."); + } + + /// + /// Obtiene y actualiza el resumen de votos a nivel provincial. + /// Esta versión mejorada utiliza una transacción para garantizar la consistencia de los datos. + /// + private async Task SondearResumenProvincialAsync(string authToken, CancellationToken stoppingToken) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var provincia = await dbContext.AmbitosGeograficos.AsNoTracking().FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken); + if (provincia == null) return; + + var resumen = await _apiService.GetResumenAsync(authToken, provincia.DistritoId!); + + // --- CAMBIO CLAVE: Lógica de actualización robusta --- + // Solo procedemos si la respuesta de la API es válida Y contiene datos de votos positivos. + if (resumen?.ValoresTotalizadosPositivos is { Count: > 0 } nuevosVotos) + { + // Usamos una transacción explícita para asegurar que la operación sea atómica: + // O se completa todo (borrado e inserción), o no se hace nada. + await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken); + + // 1. Borramos los datos viejos. + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ResumenesVotos", stoppingToken); + + // 2. Insertamos los nuevos datos. + foreach (var voto in nuevosVotos) + { + dbContext.ResumenesVotos.Add(new ResumenVoto + { + AmbitoGeograficoId = provincia.Id, + AgrupacionPoliticaId = voto.IdAgrupacion, + Votos = voto.Votos, + VotosPorcentaje = voto.VotosPorcentaje + }); + } + + // 3. Guardamos los cambios y confirmamos la transacción. + await dbContext.SaveChangesAsync(stoppingToken); + await transaction.CommitAsync(stoppingToken); + + _logger.LogInformation("Sondeo de Resumen Provincial completado. La tabla ha sido actualizada."); + } + else + { + // Si la API no devuelve datos de votos, no hacemos NADA en la base de datos. + _logger.LogInformation("Sondeo de Resumen Provincial completado. No se recibieron datos nuevos, la tabla no fue modificada."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Ocurrió un error en el sondeo de Resumen Provincial."); + } + } + + /// + /// Obtiene y actualiza el estado general del recuento a nivel provincial para CADA categoría electoral. + /// Esta versión es robusta: consulta dinámicamente las categorías, usa la clave primaria compuesta + /// de la base de datos y guarda todos los cambios en una única transacción al final. + /// + /// El token de autenticación válido para la sesión. + /// El token de cancelación para detener la operación. + private async Task SondearEstadoRecuentoGeneralAsync(string authToken, CancellationToken stoppingToken) + { + try + { + // PASO 1: Crear un "scope" para obtener una instancia fresca de DbContext. + // Esto es una práctica recomendada para servicios de larga duración para evitar problemas de concurrencia. + using var scope = _serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // PASO 2: Obtener el ámbito geográfico de la Provincia. + // Necesitamos este objeto para obtener su 'DistritoId' ("02"), que es requerido por la API. + var provincia = await dbContext.AmbitosGeograficos + .AsNoTracking() // Optimización: Solo necesitamos leer datos, no modificarlos. + .FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken); + + // Comprobación de seguridad: Si la sincronización inicial falló y no tenemos el registro de la provincia, + // no podemos continuar. Registramos una advertencia y salimos del método. + if (provincia == null) + { + _logger.LogWarning("No se encontró el ámbito 'Provincia' (NivelId 10) en la BD. Omitiendo sondeo de estado general."); + return; + } + + // PASO 3: Obtener todas las categorías electorales disponibles desde nuestra base de datos. + // Esto hace que el método sea dinámico y no dependa de IDs fijos en el código. + var categoriasParaSondear = await dbContext.CategoriasElectorales + .AsNoTracking() + .ToListAsync(stoppingToken); + + if (!categoriasParaSondear.Any()) + { + _logger.LogWarning("No hay categorías en la BD para sondear el estado general del recuento."); + return; + } + + _logger.LogInformation("Iniciando sondeo de Estado Recuento General para {count} categorías...", categoriasParaSondear.Count); + + // PASO 4: Iterar sobre cada categoría para obtener su estado de recuento individual. + foreach (var categoria in categoriasParaSondear) + { + // Salimos limpiamente del bucle si la aplicación se está deteniendo. + if (stoppingToken.IsCancellationRequested) break; + + // Llamamos a la API con el distrito y la CATEGORÍA ACTUAL del bucle. + var estadoDto = await _apiService.GetEstadoRecuentoGeneralAsync(authToken, provincia.DistritoId!, categoria.Id); + + // Solo procedemos si la API devolvió datos válidos. + if (estadoDto != null) + { + // Lógica "Upsert" (Update or Insert): + // Buscamos un registro existente usando la CLAVE PRIMARIA COMPUESTA. + var registroDb = await dbContext.EstadosRecuentosGenerales.FindAsync( + new object[] { provincia.Id, categoria.Id }, + cancellationToken: stoppingToken + ); + + // Si no se encuentra (FindAsync devuelve null), es un registro nuevo. + if (registroDb == null) + { + // Creamos una nueva instancia de la entidad. + registroDb = new EstadoRecuentoGeneral + { + AmbitoGeograficoId = provincia.Id, + CategoriaId = categoria.Id // Asignamos ambas partes de la clave primaria. + }; + // Y la añadimos al ChangeTracker de EF para que la inserte en la BD. + dbContext.EstadosRecuentosGenerales.Add(registroDb); + } + + // Mapeamos los datos del DTO de la API a nuestra entidad de base de datos. + // Esto se hace tanto para registros nuevos como para los existentes que se van a actualizar. + registroDb.MesasEsperadas = estadoDto.MesasEsperadas; + registroDb.MesasTotalizadas = estadoDto.MesasTotalizadas; + registroDb.MesasTotalizadasPorcentaje = estadoDto.MesasTotalizadasPorcentaje; + registroDb.CantidadElectores = estadoDto.CantidadElectores; + registroDb.CantidadVotantes = estadoDto.CantidadVotantes; + registroDb.ParticipacionPorcentaje = estadoDto.ParticipacionPorcentaje; + } + } + + // PASO 5: Guardar todos los cambios en la base de datos. + // Al llamar a SaveChangesAsync UNA SOLA VEZ fuera del bucle, EF Core agrupa + // todas las inserciones y actualizaciones en una única transacción eficiente. + await dbContext.SaveChangesAsync(stoppingToken); + _logger.LogInformation("Sondeo de Estado Recuento General completado para todas las categorías."); + } + catch (Exception ex) + { + // Capturamos cualquier excepción inesperada para que no detenga el worker y la registramos. + _logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Estado Recuento General."); + } + } + + // Pega aquí los métodos: + // - SondearResultadosMunicipalesAsync + // - GuardarResultadosDeMunicipiosAsync + // - SondearResumenProvincialAsync + // - SondearEstadoRecuentoGeneralAsync + // (Estos métodos necesitan IServiceProvider y SharedTokenService, que ya están inyectados) +} diff --git a/Elecciones-Web/src/Elecciones.Worker/LowPriorityDataWorker.cs b/Elecciones-Web/src/Elecciones.Worker/LowPriorityDataWorker.cs new file mode 100644 index 0000000..b3306a3 --- /dev/null +++ b/Elecciones-Web/src/Elecciones.Worker/LowPriorityDataWorker.cs @@ -0,0 +1,433 @@ +using Elecciones.Database; +using Elecciones.Database.Entities; +using Elecciones.Infrastructure.Services; +using Microsoft.EntityFrameworkCore; + +namespace Elecciones.Worker; + +public class LowPriorityDataWorker : BackgroundService +{ + private readonly ILogger _logger; + private readonly SharedTokenService _tokenService; + private readonly IServiceProvider _serviceProvider; + private readonly IElectoralApiService _apiService; + + public LowPriorityDataWorker( + ILogger 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; + } + } + } + + /// + /// 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. + /// + /// El token de cancelación para detener la operación. + 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(); + + // 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(); + 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."); + } + } + + + + /// + /// 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. + /// + /// + /// 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. + /// + private async Task SondearProyeccionBancasAsync(string authToken, CancellationToken stoppingToken) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + 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(); + + // 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."); + } + } + + /// + /// 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. + /// + /// El token de autenticación válido para la sesión. + /// El token de cancelación para detener la operación. + 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(); + + 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(); + + 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) +} \ No newline at end of file diff --git a/Elecciones-Web/src/Elecciones.Worker/Program.cs b/Elecciones-Web/src/Elecciones.Worker/Program.cs index 47c17a4..448aa68 100644 --- a/Elecciones-Web/src/Elecciones.Worker/Program.cs +++ b/Elecciones-Web/src/Elecciones.Worker/Program.cs @@ -104,7 +104,13 @@ builder.Services.AddHttpClient("ElectoralApiClient", client => */ builder.Services.AddScoped(); -builder.Services.AddHostedService(); +// Registramos el servicio de token como un Singleton para que sea compartido. +builder.Services.AddSingleton(); + +// Registramos ambos workers. El framework se encargará de iniciarlos y detenerlos. +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +//builder.Services.AddHostedService(); var host = builder.Build(); diff --git a/Elecciones-Web/src/Elecciones.Worker/Worker.cs b/Elecciones-Web/src/Elecciones.Worker/Worker.cs deleted file mode 100644 index c2f54ac..0000000 --- a/Elecciones-Web/src/Elecciones.Worker/Worker.cs +++ /dev/null @@ -1,848 +0,0 @@ -using Elecciones.Database; -using Elecciones.Database.Entities; -using Elecciones.Infrastructure.Services; -using Microsoft.EntityFrameworkCore; -using System.Collections.Concurrent; -using Microsoft.Extensions.Logging; -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace Elecciones.Worker; - -/// -/// Servicio de fondo (BackgroundService) responsable de sincronizar y sondear -/// periódicamente los datos de la API electoral. -/// -public class Worker : BackgroundService -{ - private readonly ILogger _logger; - private readonly IElectoralApiService _apiService; - private readonly IServiceProvider _serviceProvider; - // --- VARIABLES DE ESTADO PARA EL TOKEN --- - private string? _authToken; - // Usamos DateTimeOffset para manejar correctamente las zonas horarias. - private DateTimeOffset _tokenExpiration = DateTimeOffset.MinValue; - - public Worker(ILogger logger, IElectoralApiService apiService, IServiceProvider serviceProvider) - { - _logger = logger; - _apiService = apiService; - _serviceProvider = serviceProvider; - } - - /// - /// Obtiene un token de autenticación válido, solicitando uno nuevo solo si el actual - /// no existe o ha expirado. - /// - private async Task GetValidAuthTokenAsync(CancellationToken stoppingToken) - { - // Comprobamos si el token es nulo o si la fecha de expiración ya pasó. - // Añadimos un buffer de seguridad de 1 minuto para renovarlo un poco antes. - if (string.IsNullOrEmpty(_authToken) || DateTimeOffset.UtcNow >= _tokenExpiration.AddMinutes(-1)) - { - _logger.LogInformation("Token no válido o a punto de expirar. Solicitando uno nuevo..."); - var tokenResponse = await _apiService.GetAuthTokenAsync(); // Asumimos que el ApiService devuelve el objeto completo - - if (tokenResponse?.Data?.AccessToken != null) - { - _authToken = tokenResponse.Data.AccessToken; - // Calculamos la nueva fecha de expiración. La API nos da la duración en segundos. - _tokenExpiration = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.Data.ExpiresIn); - _logger.LogInformation("Nuevo token obtenido. Válido hasta: {expiration}", _tokenExpiration); - } - else - { - _logger.LogError("CRÍTICO: No se pudo obtener un nuevo token de autenticación."); - _authToken = null; // Nos aseguramos de que el token viejo se invalide - } - } - - return _authToken; - } - - /// - /// Método principal del worker que se ejecuta en segundo plano. - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("Elecciones Worker iniciado a las: {time}", DateTimeOffset.Now); - - await SincronizarCatalogosMaestrosAsync(stoppingToken); - - _logger.LogInformation("-------------------------------------------------"); - _logger.LogInformation("Iniciando sondeo periódico de resultados..."); - _logger.LogInformation("-------------------------------------------------"); - - int cicloContador = 0; - - while (!stoppingToken.IsCancellationRequested) - { - var cicloInicio = DateTime.UtcNow; - cicloContador++; - - var authToken = await GetValidAuthTokenAsync(stoppingToken); - - if (string.IsNullOrEmpty(authToken)) - { - _logger.LogError("No se pudo obtener un token válido. Reintentando en 1 minuto..."); - await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); - continue; - } - - // --- CICLO CALIENTE: TAREAS DE ALTA PRIORIDAD (SIEMPRE SE EJECUTAN) --- - _logger.LogInformation("--- Iniciando Ciclo Caliente #{ciclo} ---", cicloContador); - - await SondearResultadosMunicipalesAsync(authToken, stoppingToken); - await SondearResumenProvincialAsync(authToken, stoppingToken); - await SondearEstadoRecuentoGeneralAsync(authToken, stoppingToken); - - // --- CICLO FRÍO: TAREAS DE BAJA PRIORIDAD (SE EJECUTAN CADA 5 CICLOS) --- - // El operador '%' (módulo) nos dice si el contador es divisible por 5. - if (cicloContador % 5 == 1) // Se ejecuta en el ciclo 1, 6, 11, etc. - { - _logger.LogInformation("--- Iniciando Ciclo Frío (Bancas y Telegramas) ---"); - await SondearProyeccionBancasAsync(authToken, stoppingToken); - await SondearNuevosTelegramasAsync(authToken, stoppingToken); - } - - var cicloFin = DateTime.UtcNow; - var duracionCiclo = cicloFin - cicloInicio; - _logger.LogInformation("Ciclo #{ciclo} completado en {duration} segundos.", cicloContador, duracionCiclo.TotalSeconds); - - // --- ESPERA INTELIGENTE --- - // Esperamos lo que quede para completar 1 minuto desde el inicio del ciclo. - // Si el ciclo tardó 20 segundos, esperamos 40. Si tardó más de 1 minuto, la espera es mínima. - var tiempoDeEspera = TimeSpan.FromMinutes(1) - duracionCiclo; - if (tiempoDeEspera < TimeSpan.Zero) - { - tiempoDeEspera = TimeSpan.FromSeconds(5); // Una espera mínima si el ciclo se excedió - } - - try - { - _logger.LogInformation("Esperando {wait_seconds} segundos para el siguiente ciclo...", tiempoDeEspera.TotalSeconds); - await Task.Delay(tiempoDeEspera, stoppingToken); - } - catch (TaskCanceledException) - { - break; - } - } - - _logger.LogInformation("Elecciones Worker se está deteniendo."); - } - - /// - /// 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. - /// - /// El token de cancelación para detener la operación. - private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingToken) - { - try - { - _logger.LogInformation("Iniciando sincronización de catálogos maestros..."); - - var authToken = await 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(); - - // 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(); - 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."); - } - } - - /// - /// Sondea los resultados electorales para todos los municipios/partidos de forma optimizada. - /// Utiliza paralelismo controlado para ejecutar múltiples peticiones a la API simultáneamente - /// sin sobrecargar la red, y luego guarda todos los resultados en la base de datos de forma masiva. - /// - /// El token de autenticación válido para la sesión. - /// El token de cancelación para detener la operación. - private async Task SondearResultadosMunicipalesAsync(string authToken, CancellationToken stoppingToken) - { - try - { - // PASO 1: Preparar el DbContext y los datos necesarios. - using var scope = _serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - // Obtenemos de nuestra BD local la lista de todos los partidos (NivelId=30) que necesitamos consultar. - var municipiosASondear = await dbContext.AmbitosGeograficos - .AsNoTracking() - .Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null) - .Select(a => new { a.Id, a.Nombre, a.MunicipioId, a.SeccionId, a.DistritoId }) - .ToListAsync(stoppingToken); - - if (!municipiosASondear.Any()) - { - _logger.LogWarning("No se encontraron Partidos (NivelId 30) en la BD para sondear resultados."); - return; - } - - // Obtenemos la categoría "CONCEJALES", ya que los resultados municipales aplican a esta. - var categoriaConcejales = await dbContext.CategoriasElectorales - .AsNoTracking() - .FirstOrDefaultAsync(c => c.Nombre.Contains("CONCEJALES"), stoppingToken); - - if (categoriaConcejales == null) - { - _logger.LogWarning("No se encontró la categoría 'CONCEJALES'. Omitiendo sondeo de resultados municipales."); - return; - } - - // PASO 2: Ejecutar las consultas a la API con paralelismo controlado. - - // Definimos cuántas peticiones queremos que se ejecuten simultáneamente. - // Un valor entre 8 y 16 es generalmente seguro y ofrece una gran mejora de velocidad. - const int GRADO_DE_PARALELISMO = 3; - // Creamos un semáforo que actuará como un "control de acceso" con 10 pases libres. - var semaforo = new SemaphoreSlim(GRADO_DE_PARALELISMO); - - // Usamos un ConcurrentDictionary para almacenar los resultados. A diferencia de un Dictionary normal, - // este permite que múltiples tareas escriban en él al mismo tiempo sin conflictos. - var resultadosPorId = new ConcurrentDictionary(); - - _logger.LogInformation("Iniciando sondeo de resultados para {count} municipios con un paralelismo de {degree}...", municipiosASondear.Count, GRADO_DE_PARALELISMO); - - // Creamos una lista de tareas (Tasks), una por cada municipio a consultar. - // El método .Select() no ejecuta las tareas todavía, solo las prepara. - var tareas = municipiosASondear.Select(async municipio => - { - // Cada tarea debe "pedir permiso" al semáforo antes de ejecutarse. - // Si ya hay 10 tareas en ejecución, esta línea esperará hasta que una termine. - await semaforo.WaitAsync(stoppingToken); - try - { - // Una vez que obtiene el permiso, ejecuta la petición a la API. - var resultados = await _apiService.GetResultadosAsync( - authToken, municipio.DistritoId!, municipio.SeccionId!, null, categoriaConcejales.Id - ); - - // Si la API devuelve datos válidos... - if (resultados != null) - { - // ...los guardamos en el diccionario concurrente. - resultadosPorId[municipio.Id] = resultados; - } - } - finally - { - // ¡CRUCIAL! Liberamos el pase del semáforo, permitiendo que la siguiente - // tarea en espera pueda comenzar su ejecución. - semaforo.Release(); - // Añadir un pequeño retraso aleatorio para no parecer un robot - await Task.Delay(TimeSpan.FromMilliseconds(new Random().Next(50, 251)), stoppingToken); - } - }); - - // Ahora sí, ejecutamos todas las tareas preparadas en paralelo y esperamos a que todas terminen. - await Task.WhenAll(tareas); - - // PASO 3: Guardar los resultados en la base de datos. - // Solo procedemos si recolectamos al menos un resultado válido. - if (resultadosPorId.Any()) - { - // Llamamos a nuestro método de guardado masivo y optimizado, pasándole todos los resultados - // recolectados para que los inserte en una única y eficiente transacción. - await GuardarResultadosDeMunicipiosAsync(dbContext, resultadosPorId.ToDictionary(kv => kv.Key, kv => kv.Value), stoppingToken); - } - } - catch (Exception ex) - { - // Capturamos cualquier error inesperado en el proceso para que el worker no se detenga. - _logger.LogError(ex, "Ocurrió un error inesperado durante el sondeo de resultados municipales."); - } - } - - /// Realiza una operación "Upsert" (Update o Insert) de forma masiva y optimizada. - /// Este método es llamado por SondearResultadosMunicipalesAsync. - /// - private async Task GuardarResultadosDeMunicipiosAsync( - EleccionesDbContext dbContext, - Dictionary todosLosResultados, - CancellationToken stoppingToken) // <-- PARÁMETRO AÑADIDO - { - // Obtenemos los IDs de todos los ámbitos que vamos a actualizar. - var ambitoIds = todosLosResultados.Keys; - - // --- OPTIMIZACIÓN 1: Cargar todos los datos existentes en memoria UNA SOLA VEZ --- - var estadosRecuentoExistentes = await dbContext.EstadosRecuentos - .Where(e => ambitoIds.Contains(e.AmbitoGeograficoId)) - .ToDictionaryAsync(e => e.AmbitoGeograficoId, stoppingToken); - - var resultadosVotosExistentes = await dbContext.ResultadosVotos - .Where(rv => ambitoIds.Contains(rv.AmbitoGeograficoId)) - .GroupBy(rv => rv.AmbitoGeograficoId) - .ToDictionaryAsync(g => g.Key, g => g.ToDictionary(item => item.AgrupacionPoliticaId), stoppingToken); - - _logger.LogInformation("Procesando en memoria los resultados de {count} municipios.", todosLosResultados.Count); - - // --- OPTIMIZACIÓN 2: Procesar todo en memoria --- - foreach (var kvp in todosLosResultados) - { - var ambitoId = kvp.Key; - var resultadosDto = kvp.Value; - - // Lógica Upsert para EstadoRecuento - if (!estadosRecuentoExistentes.TryGetValue(ambitoId, out var estadoRecuento)) - { - estadoRecuento = new EstadoRecuento { AmbitoGeograficoId = ambitoId }; - dbContext.EstadosRecuentos.Add(estadoRecuento); - } - - // Mapeo completo de propiedades para EstadoRecuento - estadoRecuento.FechaTotalizacion = DateTime.Parse(resultadosDto.FechaTotalizacion).ToUniversalTime(); - estadoRecuento.MesasEsperadas = resultadosDto.EstadoRecuento.MesasEsperadas; - estadoRecuento.MesasTotalizadas = resultadosDto.EstadoRecuento.MesasTotalizadas; - estadoRecuento.MesasTotalizadasPorcentaje = resultadosDto.EstadoRecuento.MesasTotalizadasPorcentaje; - estadoRecuento.CantidadElectores = resultadosDto.EstadoRecuento.CantidadElectores; - estadoRecuento.CantidadVotantes = resultadosDto.EstadoRecuento.CantidadVotantes; - estadoRecuento.ParticipacionPorcentaje = resultadosDto.EstadoRecuento.ParticipacionPorcentaje; - - if (resultadosDto.ValoresTotalizadosOtros != null) - { - estadoRecuento.VotosEnBlanco = resultadosDto.ValoresTotalizadosOtros.VotosEnBlanco; - estadoRecuento.VotosEnBlancoPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosEnBlancoPorcentaje; - estadoRecuento.VotosNulos = resultadosDto.ValoresTotalizadosOtros.VotosNulos; - estadoRecuento.VotosNulosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosNulosPorcentaje; - estadoRecuento.VotosRecurridos = resultadosDto.ValoresTotalizadosOtros.VotosRecurridos; - estadoRecuento.VotosRecurridosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosRecurridosPorcentaje; - } - - // Lógica Upsert para ResultadosVotos - var votosDeAmbitoExistentes = resultadosVotosExistentes.GetValueOrDefault(ambitoId); - foreach (var votoPositivoDto in resultadosDto.ValoresTotalizadosPositivos) - { - ResultadoVoto? resultadoVoto = null; - if (votosDeAmbitoExistentes != null) - { - votosDeAmbitoExistentes.TryGetValue(votoPositivoDto.IdAgrupacion, out resultadoVoto); - } - - if (resultadoVoto == null) - { - resultadoVoto = new ResultadoVoto - { - AmbitoGeograficoId = ambitoId, - AgrupacionPoliticaId = votoPositivoDto.IdAgrupacion - }; - dbContext.ResultadosVotos.Add(resultadoVoto); - } - resultadoVoto.CantidadVotos = votoPositivoDto.Votos; - resultadoVoto.PorcentajeVotos = votoPositivoDto.VotosPorcentaje; - } - } - - // --- OPTIMIZACIÓN 3: Guardar todos los cambios en UNA SOLA TRANSACCIÓN --- - _logger.LogInformation("Guardando todos los cambios de resultados municipales en la base de datos..."); - // Ahora 'stoppingToken' es reconocido aquí - await dbContext.SaveChangesAsync(stoppingToken); - _logger.LogInformation("Guardado completado."); - } - - /// - /// 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. - /// - /// - /// 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. - /// - private async Task SondearProyeccionBancasAsync(string authToken, CancellationToken stoppingToken) - { - try - { - using var scope = _serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - 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(); - - // 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."); - } - } - - /// - /// 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. - /// - /// El token de autenticación válido para la sesión. - /// El token de cancelación para detener la operación. - 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(); - - 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(); - - 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."); - } - } - - /// - /// Obtiene y actualiza el resumen de votos a nivel provincial. - /// Esta versión mejorada utiliza una transacción para garantizar la consistencia de los datos. - /// - private async Task SondearResumenProvincialAsync(string authToken, CancellationToken stoppingToken) - { - try - { - using var scope = _serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - var provincia = await dbContext.AmbitosGeograficos.AsNoTracking().FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken); - if (provincia == null) return; - - var resumen = await _apiService.GetResumenAsync(authToken, provincia.DistritoId!); - - // --- CAMBIO CLAVE: Lógica de actualización robusta --- - // Solo procedemos si la respuesta de la API es válida Y contiene datos de votos positivos. - if (resumen?.ValoresTotalizadosPositivos is { Count: > 0 } nuevosVotos) - { - // Usamos una transacción explícita para asegurar que la operación sea atómica: - // O se completa todo (borrado e inserción), o no se hace nada. - await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken); - - // 1. Borramos los datos viejos. - await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ResumenesVotos", stoppingToken); - - // 2. Insertamos los nuevos datos. - foreach (var voto in nuevosVotos) - { - dbContext.ResumenesVotos.Add(new ResumenVoto - { - AmbitoGeograficoId = provincia.Id, - AgrupacionPoliticaId = voto.IdAgrupacion, - Votos = voto.Votos, - VotosPorcentaje = voto.VotosPorcentaje - }); - } - - // 3. Guardamos los cambios y confirmamos la transacción. - await dbContext.SaveChangesAsync(stoppingToken); - await transaction.CommitAsync(stoppingToken); - - _logger.LogInformation("Sondeo de Resumen Provincial completado. La tabla ha sido actualizada."); - } - else - { - // Si la API no devuelve datos de votos, no hacemos NADA en la base de datos. - _logger.LogInformation("Sondeo de Resumen Provincial completado. No se recibieron datos nuevos, la tabla no fue modificada."); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Ocurrió un error en el sondeo de Resumen Provincial."); - } - } - - /// - /// Obtiene y actualiza el estado general del recuento a nivel provincial para CADA categoría electoral. - /// Esta versión es robusta: consulta dinámicamente las categorías, usa la clave primaria compuesta - /// de la base de datos y guarda todos los cambios en una única transacción al final. - /// - /// El token de autenticación válido para la sesión. - /// El token de cancelación para detener la operación. - private async Task SondearEstadoRecuentoGeneralAsync(string authToken, CancellationToken stoppingToken) - { - try - { - // PASO 1: Crear un "scope" para obtener una instancia fresca de DbContext. - // Esto es una práctica recomendada para servicios de larga duración para evitar problemas de concurrencia. - using var scope = _serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - // PASO 2: Obtener el ámbito geográfico de la Provincia. - // Necesitamos este objeto para obtener su 'DistritoId' ("02"), que es requerido por la API. - var provincia = await dbContext.AmbitosGeograficos - .AsNoTracking() // Optimización: Solo necesitamos leer datos, no modificarlos. - .FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken); - - // Comprobación de seguridad: Si la sincronización inicial falló y no tenemos el registro de la provincia, - // no podemos continuar. Registramos una advertencia y salimos del método. - if (provincia == null) - { - _logger.LogWarning("No se encontró el ámbito 'Provincia' (NivelId 10) en la BD. Omitiendo sondeo de estado general."); - return; - } - - // PASO 3: Obtener todas las categorías electorales disponibles desde nuestra base de datos. - // Esto hace que el método sea dinámico y no dependa de IDs fijos en el código. - var categoriasParaSondear = await dbContext.CategoriasElectorales - .AsNoTracking() - .ToListAsync(stoppingToken); - - if (!categoriasParaSondear.Any()) - { - _logger.LogWarning("No hay categorías en la BD para sondear el estado general del recuento."); - return; - } - - _logger.LogInformation("Iniciando sondeo de Estado Recuento General para {count} categorías...", categoriasParaSondear.Count); - - // PASO 4: Iterar sobre cada categoría para obtener su estado de recuento individual. - foreach (var categoria in categoriasParaSondear) - { - // Salimos limpiamente del bucle si la aplicación se está deteniendo. - if (stoppingToken.IsCancellationRequested) break; - - // Llamamos a la API con el distrito y la CATEGORÍA ACTUAL del bucle. - var estadoDto = await _apiService.GetEstadoRecuentoGeneralAsync(authToken, provincia.DistritoId!, categoria.Id); - - // Solo procedemos si la API devolvió datos válidos. - if (estadoDto != null) - { - // Lógica "Upsert" (Update or Insert): - // Buscamos un registro existente usando la CLAVE PRIMARIA COMPUESTA. - var registroDb = await dbContext.EstadosRecuentosGenerales.FindAsync( - new object[] { provincia.Id, categoria.Id }, - cancellationToken: stoppingToken - ); - - // Si no se encuentra (FindAsync devuelve null), es un registro nuevo. - if (registroDb == null) - { - // Creamos una nueva instancia de la entidad. - registroDb = new EstadoRecuentoGeneral - { - AmbitoGeograficoId = provincia.Id, - CategoriaId = categoria.Id // Asignamos ambas partes de la clave primaria. - }; - // Y la añadimos al ChangeTracker de EF para que la inserte en la BD. - dbContext.EstadosRecuentosGenerales.Add(registroDb); - } - - // Mapeamos los datos del DTO de la API a nuestra entidad de base de datos. - // Esto se hace tanto para registros nuevos como para los existentes que se van a actualizar. - registroDb.MesasEsperadas = estadoDto.MesasEsperadas; - registroDb.MesasTotalizadas = estadoDto.MesasTotalizadas; - registroDb.MesasTotalizadasPorcentaje = estadoDto.MesasTotalizadasPorcentaje; - registroDb.CantidadElectores = estadoDto.CantidadElectores; - registroDb.CantidadVotantes = estadoDto.CantidadVotantes; - registroDb.ParticipacionPorcentaje = estadoDto.ParticipacionPorcentaje; - } - } - - // PASO 5: Guardar todos los cambios en la base de datos. - // Al llamar a SaveChangesAsync UNA SOLA VEZ fuera del bucle, EF Core agrupa - // todas las inserciones y actualizaciones en una única transacción eficiente. - await dbContext.SaveChangesAsync(stoppingToken); - _logger.LogInformation("Sondeo de Estado Recuento General completado para todas las categorías."); - } - catch (Exception ex) - { - // Capturamos cualquier excepción inesperada para que no detenga el worker y la registramos. - _logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Estado Recuento General."); - } - } -} \ No newline at end of file diff --git a/Elecciones-Web/src/Elecciones.Worker/obj/Debug/net9.0/Elecciones.Worker.AssemblyInfo.cs b/Elecciones-Web/src/Elecciones.Worker/obj/Debug/net9.0/Elecciones.Worker.AssemblyInfo.cs index cb1bf86..fb941ea 100644 --- a/Elecciones-Web/src/Elecciones.Worker/obj/Debug/net9.0/Elecciones.Worker.AssemblyInfo.cs +++ b/Elecciones-Web/src/Elecciones.Worker/obj/Debug/net9.0/Elecciones.Worker.AssemblyInfo.cs @@ -14,7 +14,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Worker")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68dce9415e165633856e4fae9b2d71cc07b4e2ff")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+19b37f73206d043982fc77f8c2359f2598889b64")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Worker")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Worker")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]