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