From 46f6eeae916053f22410e22cf503a21b006a87ce Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 18 Aug 2025 13:48:55 -0300 Subject: [PATCH] Fix Worker 1348 --- .../Elecciones.Worker.csproj | 1 + .../src/Elecciones.Worker/Program.cs | 27 ++- .../src/Elecciones.Worker/Worker.cs | 159 +++++++++++++++--- .../net9.0/Elecciones.Worker.AssemblyInfo.cs | 2 +- ...Elecciones.Worker.csproj.nuget.dgspec.json | 4 + 5 files changed, 162 insertions(+), 31 deletions(-) diff --git a/Elecciones-Web/src/Elecciones.Worker/Elecciones.Worker.csproj b/Elecciones-Web/src/Elecciones.Worker/Elecciones.Worker.csproj index b79f54c..151dee8 100644 --- a/Elecciones-Web/src/Elecciones.Worker/Elecciones.Worker.csproj +++ b/Elecciones-Web/src/Elecciones.Worker/Elecciones.Worker.csproj @@ -11,6 +11,7 @@ + diff --git a/Elecciones-Web/src/Elecciones.Worker/Program.cs b/Elecciones-Web/src/Elecciones.Worker/Program.cs index 74111b5..9af9cfa 100644 --- a/Elecciones-Web/src/Elecciones.Worker/Program.cs +++ b/Elecciones-Web/src/Elecciones.Worker/Program.cs @@ -9,6 +9,8 @@ using Serilog; using System.Net.Http; using System.Net.Security; using System.Security.Authentication; +using Polly; +using Polly.Extensions.Http; Log.Logger = new LoggerConfiguration() .WriteTo.Console() @@ -37,7 +39,7 @@ builder.Services.AddHttpClient("ElectoralApiClient", client => { client.BaseAddress = new Uri(baseUrl); } - + // --- TIMEOUT MÁS LARGO --- // Aumentamos el tiempo de espera a 90 segundos. // Esto le dará a las peticiones lentas de la API tiempo suficiente para responder. @@ -75,7 +77,9 @@ builder.Services.AddHttpClient("ElectoralApiClient", client => } return handler; -}); +}) + +.AddPolicyHandler(GetRetryPolicy()); builder.Services.AddSingleton(); @@ -83,7 +87,8 @@ builder.Services.AddHostedService(); var host = builder.Build(); -try { +try +{ host.Run(); } catch (Exception ex) @@ -93,4 +98,20 @@ catch (Exception ex) finally { Log.CloseAndFlush(); +} + +static IAsyncPolicy GetRetryPolicy() +{ + return HttpPolicyExtensions + // Manejar peticiones que fallaron por errores de red O que devolvieron un error de servidor (como 504) + .HandleTransientHttpError() + // O que devolvieron un código 504 Gateway Timeout específicamente + .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.GatewayTimeout) + // Esperar y reintentar. La espera se duplica en cada reintento. + .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + onRetry: (outcome, timespan, retryAttempt, context) => + { + // Opcional: Loguear cada reintento. Necesitarías pasar ILogger aquí. + // Log.Warning("Retrying due to {StatusCode}. Waited {seconds}s. Attempt {retryAttempt}/3", outcome.Result.StatusCode, timespan.TotalSeconds, retryAttempt); + }); } \ No newline at end of file diff --git a/Elecciones-Web/src/Elecciones.Worker/Worker.cs b/Elecciones-Web/src/Elecciones.Worker/Worker.cs index 00267fc..2ab3e8d 100644 --- a/Elecciones-Web/src/Elecciones.Worker/Worker.cs +++ b/Elecciones-Web/src/Elecciones.Worker/Worker.cs @@ -318,68 +318,110 @@ public class Worker : BackgroundService await dbContext.SaveChangesAsync(stoppingToken); } + /// + /// Sondea la proyección de bancas para diputados y senadores. + /// Este método busca dinámicamente en la base de datos las categorías relevantes (Senadores/Diputados) + /// y los ámbitos de "Sección Electoral" (NivelId = 20), que es el nivel al que se reparten las bancas. + /// Luego, consulta la API para cada combinación y actualiza la tabla de proyecciones. + /// + /// El token de autenticación válido para la sesión. + /// El token de cancelación para detener la operación. private async Task SondearProyeccionBancasAsync(string authToken, CancellationToken stoppingToken) { try { + // PASO 1: Preparar el entorno + // Creamos un scope de DbContext para esta operación específica, una buena práctica en servicios de larga duración. using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); + // PASO 2: Obtener las categorías que reparten bancas (Senadores y Diputados) + // Hacemos una consulta a nuestra tabla local de categorías para que el código sea dinámico + // y no dependa de IDs fijos (hardcodeados). var categoriasDeBancas = await dbContext.CategoriasElectorales - .AsNoTracking() + .AsNoTracking() // Optimización de rendimiento: solo vamos a leer estos datos. .Where(c => c.Nombre.Contains("SENADORES") || c.Nombre.Contains("DIPUTADOS")) .ToListAsync(stoppingToken); + // Si por alguna razón estas categorías no están en la BD, no podemos continuar. if (!categoriasDeBancas.Any()) { _logger.LogWarning("No se encontraron categorías para 'Senadores' o 'Diputados' en la BD. Omitiendo sondeo de bancas."); return; } - var secciones = await dbContext.AmbitosGeograficos + // PASO 3: Obtener las "Secciones Electorales" (NivelId 20) + // Esta es la corrección clave. Basado en la respuesta real de la API, las Secciones Electorales + // (Primera, Segunda, Tercera, etc.) usan NivelId = 20. + var seccionesElectorales = await dbContext.AmbitosGeograficos .AsNoTracking() - .Where(a => a.NivelId == 4 && a.DistritoId != null && a.SeccionId != null) + .Where(a => a.NivelId == 20 && a.DistritoId != null && a.SeccionProvincialId != null) .ToListAsync(stoppingToken); - if (!secciones.Any()) + // Si no se encuentra ninguna Sección Electoral en la BD (lo cual sería raro después de una sincronización exitosa), + // registramos una advertencia y salimos. + if (!seccionesElectorales.Any()) { - _logger.LogWarning("No se encontraron ámbitos de tipo 'Sección Electoral' en la BD para sondear bancas."); + _logger.LogWarning("No se encontraron ámbitos de tipo 'Sección Electoral' (NivelId 20) en la BD para sondear bancas."); return; } - _logger.LogInformation("Iniciando sondeo de Bancas para {count} secciones y {catCount} categorías...", secciones.Count, categoriasDeBancas.Count); + _logger.LogInformation("Iniciando sondeo de Bancas para {count} secciones electorales y {catCount} categorías...", seccionesElectorales.Count, categoriasDeBancas.Count); + // PASO 4: Iterar, consultar la API y preparar los datos para guardar + + // Esta bandera es crucial para la estrategia de "borrar y reemplazar". + // Nos asegura que la tabla de proyecciones se vacía UNA SOLA VEZ, justo antes de insertar + // el primer lote de datos nuevos, evitando datos inconsistentes. bool hasReceivedAnyNewData = false; - foreach (var seccion in secciones) + + // Bucle externo: recorremos cada una de las 8 Secciones Electorales. + foreach (var seccion in seccionesElectorales) { - if (stoppingToken.IsCancellationRequested) break; + if (stoppingToken.IsCancellationRequested) break; // Salida limpia si la aplicación se detiene. + + // Bucle interno: para cada sección, consultamos las bancas de Senadores y Diputados. foreach (var categoria in categoriasDeBancas) { if (stoppingToken.IsCancellationRequested) break; - var repartoBancas = await _apiService.GetBancasAsync(authToken, seccion.DistritoId!, seccion.SeccionId!, categoria.Id); + + // Llamamos a la API. El endpoint 'getBancas' requiere 'distritoId' y 'seccionProvincialId', + // que son precisamente los datos que tenemos en nuestros ámbitos de NivelId = 20. + var repartoBancas = await _apiService.GetBancasAsync(authToken, seccion.DistritoId!, seccion.SeccionProvincialId!, categoria.Id); + + // Verificamos que la respuesta de la API no sea nula y que contenga al menos una banca repartida. if (repartoBancas?.RepartoBancas is { Count: > 0 }) { + // Si esta es la PRIMERA VEZ en todo el sondeo que recibimos datos válidos... if (!hasReceivedAnyNewData) { - _logger.LogInformation("Se recibieron nuevos datos de bancas. Limpiando la tabla de proyecciones para evitar duplicados..."); + _logger.LogInformation("Se recibieron nuevos datos de bancas. Limpiando la tabla de proyecciones para la actualización..."); + // ...ejecutamos un comando SQL para borrar todos los datos viejos de la tabla. await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ProyeccionesBancas", stoppingToken); + // Activamos la bandera para no volver a ejecutar este borrado. hasReceivedAnyNewData = true; } + // Procesamos cada banca obtenida en la respuesta de la API. foreach (var banca in repartoBancas.RepartoBancas) { + // Creamos una nueva entidad 'ProyeccionBanca'. var nuevaProyeccion = new ProyeccionBanca { AmbitoGeograficoId = seccion.Id, AgrupacionPoliticaId = banca.IdAgrupacion, NroBancas = banca.NroBancas }; + // Y la añadimos al ChangeTracker de EF para que la inserte. await dbContext.ProyeccionesBancas.AddAsync(nuevaProyeccion, stoppingToken); } } } } + // PASO 5: Guardar los cambios en la Base de Datos + // Si la bandera 'hasReceivedAnyNewData' se activó, significa que hemos añadido nuevas proyecciones + // al DbContext y necesitamos persistirlas. if (hasReceivedAnyNewData) { await dbContext.SaveChangesAsync(stoppingToken); @@ -387,83 +429,127 @@ public class Worker : BackgroundService } else { - _logger.LogInformation("Sondeo de Bancas completado. No se encontraron datos nuevos, la tabla no fue modificada."); + // Si no se recibieron datos nuevos, no hacemos nada en la BD. + _logger.LogInformation("Sondeo de Bancas completado. No se encontraron datos nuevos de proyección, la tabla no fue modificada."); } } catch (Exception ex) { + // Capturamos cualquier error inesperado para que no detenga el worker por completo. _logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Bancas."); } } + /// + /// Busca en la API si hay nuevos telegramas totalizados que no se encuentren en la base de datos local. + /// Este método itera sobre cada Partido/Municipio (que la API identifica como "Sección" con NivelId = 30), + /// obtiene la lista de IDs de telegramas para cada uno, los compara con los IDs locales, + /// y finalmente descarga y guarda solo los que son nuevos. + /// + /// 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 { + // PASO 1: Preparar el DbContext + // Creamos un scope para obtener una instancia fresca de DbContext para esta operación. using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - var secciones = await dbContext.AmbitosGeograficos - .AsNoTracking() - .Where(a => a.NivelId == 4 && a.DistritoId != null && a.SeccionId != null) + // PASO 2: Obtener los Partidos/Municipios (NivelId = 30) + // --- CORRECCIÓN CLAVE --- + // La API requiere un 'seccionId' para listar telegramas, y el manual lo define como "Id del partido". + // La respuesta de getCatalogo nos confirmó que los registros de Partidos/Municipios usan NivelId = 30. + var partidos = await dbContext.AmbitosGeograficos + .AsNoTracking() // Optimización: Solo necesitamos leer estos datos. + .Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null) .ToListAsync(stoppingToken); - if (!secciones.Any()) + // Si la sincronización inicial no cargó ningún partido, no podemos continuar. + if (!partidos.Any()) { - _logger.LogWarning("No hay Secciones Electorales en la BD para sondear telegramas."); + _logger.LogWarning("No se encontraron Partidos (NivelId 30) en la BD para sondear telegramas."); return; } - _logger.LogInformation("Iniciando sondeo de Telegramas nuevos..."); + _logger.LogInformation("Iniciando sondeo de Telegramas nuevos para {count} partidos...", partidos.Count); - foreach (var seccion in secciones) + // PASO 3: Iterar sobre cada Partido para buscar telegramas. + foreach (var partido in partidos) { - if (stoppingToken.IsCancellationRequested) break; + if (stoppingToken.IsCancellationRequested) break; // Salida limpia si la aplicación se detiene. - var listaTelegramasApi = await _apiService.GetTelegramasTotalizadosAsync(authToken, seccion.DistritoId!, seccion.SeccionId!); + // 3.1 - OBTENER LA LISTA COMPLETA DE IDs DE LA API PARA EL PARTIDO ACTUAL + var listaTelegramasApi = await _apiService.GetTelegramasTotalizadosAsync(authToken, partido.DistritoId!, partido.SeccionId!); + + // Solo procesamos si la API devolvió una lista con al menos un telegrama. if (listaTelegramasApi is { Count: > 0 }) { + // La API devuelve una lista de arrays de string (ej. [ ["id1"], ["id2"] ]). + // Usamos .Select(t => t[0]) para extraer solo el ID de cada sub-array. var idsDeApi = listaTelegramasApi.Select(t => t[0]).Distinct().ToList(); + + // 3.2 - COMPARAR CON LA BASE DE DATOS LOCAL PARA ENCONTRAR LOS NUEVOS + // Esta es una consulta muy eficiente: le pedimos a la BD que nos devuelva solo los IDs que + // ya existen de la lista que nos acaba de dar la API. var idsYaEnDb = await dbContext.Telegramas .Where(t => idsDeApi.Contains(t.Id)) .Select(t => t.Id) .ToListAsync(stoppingToken); + // Comparamos las dos listas para obtener una tercera: la de los IDs que están en la API pero no en nuestra BD. var nuevosTelegramasIds = idsDeApi.Except(idsYaEnDb).ToList(); + + // Si no hay telegramas nuevos para este partido, simplemente continuamos con el siguiente. if (!nuevosTelegramasIds.Any()) { continue; } - _logger.LogInformation("Se encontraron {count} telegramas nuevos en la sección {seccionId}. Descargando...", nuevosTelegramasIds.Count, seccion.SeccionId); + _logger.LogInformation("Se encontraron {count} telegramas nuevos en el partido '{nombrePartido}' ({seccionId}). Descargando...", nuevosTelegramasIds.Count, partido.Nombre, partido.SeccionId); + + // 3.3 - DESCARGAR Y GUARDAR CADA NUEVO TELEGRAMA foreach (var mesaId in nuevosTelegramasIds) { if (stoppingToken.IsCancellationRequested) break; + + // Descargamos el contenido completo del telegrama (imagen base64 y metadatos). var telegramaFile = await _apiService.GetTelegramaFileAsync(authToken, mesaId); if (telegramaFile != null) { var nuevoTelegrama = new Telegrama { Id = telegramaFile.NombreArchivo, - AmbitoGeograficoId = seccion.Id, + AmbitoGeograficoId = partido.Id, // Lo asociamos al ID del partido. ContenidoBase64 = telegramaFile.Imagen, FechaEscaneo = DateTime.Parse(telegramaFile.FechaEscaneo).ToUniversalTime(), FechaTotalizacion = DateTime.Parse(telegramaFile.FechaTotalizacion).ToUniversalTime() }; + // Añadimos la nueva entidad a la sesión de EF Core para su inserción. await dbContext.Telegramas.AddAsync(nuevoTelegrama, stoppingToken); } } + + // Guardamos los cambios en la base de datos después de procesar todos los telegramas nuevos de UN partido. + // Esto es más eficiente que guardar en cada iteración del bucle interno. await dbContext.SaveChangesAsync(stoppingToken); } } + _logger.LogInformation("Sondeo de Telegramas completado."); } catch (Exception ex) { + // Capturamos cualquier error para que no detenga el worker y lo registramos para depuración. _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 @@ -475,21 +561,40 @@ public class Worker : BackgroundService if (provincia == null) return; var resumen = await _apiService.GetResumenAsync(authToken, provincia.DistritoId!); - if (resumen?.ValoresTotalizadosPositivos is { Count: > 0 }) + + // --- 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); - foreach (var voto in resumen.ValoresTotalizadosPositivos) + + // 2. Insertamos los nuevos datos. + foreach (var voto in nuevosVotos) { - await dbContext.ResumenesVotos.AddAsync(new ResumenVoto + dbContext.ResumenesVotos.Add(new ResumenVoto { AmbitoGeograficoId = provincia.Id, AgrupacionPoliticaId = voto.IdAgrupacion, Votos = voto.Votos, VotosPorcentaje = voto.VotosPorcentaje - }, stoppingToken); + }); } + + // 3. Guardamos los cambios y confirmamos la transacción. await dbContext.SaveChangesAsync(stoppingToken); - _logger.LogInformation("Sondeo de Resumen Provincial completado."); + 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) 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 d100b33..469da4a 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+30f1e751b770bf730fc48b1baefb00f560694f35")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+a4e47b6e3d1f8b0746f4f910f56a94e17b2e030c")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Worker")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Worker")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/Elecciones-Web/src/Elecciones.Worker/obj/Elecciones.Worker.csproj.nuget.dgspec.json b/Elecciones-Web/src/Elecciones.Worker/obj/Elecciones.Worker.csproj.nuget.dgspec.json index ea147cf..ee7ae44 100644 --- a/Elecciones-Web/src/Elecciones.Worker/obj/Elecciones.Worker.csproj.nuget.dgspec.json +++ b/Elecciones-Web/src/Elecciones.Worker/obj/Elecciones.Worker.csproj.nuget.dgspec.json @@ -290,6 +290,10 @@ "target": "Package", "version": "[9.0.8, )" }, + "Microsoft.Extensions.Http.Polly": { + "target": "Package", + "version": "[9.0.8, )" + }, "Serilog.Extensions.Hosting": { "target": "Package", "version": "[9.0.0, )"