403 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			403 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| 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;
 | |
| 
 | |
|   public CriticalDataWorker(
 | |
|       ILogger<CriticalDataWorker> 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 Datos Críticos iniciado.");
 | |
| 
 | |
|     try
 | |
|     {
 | |
|       await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
 | |
|     }
 | |
|     catch (TaskCanceledException) { return; }
 | |
| 
 | |
|     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; }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private async Task SondearResultadosMunicipalesAsync(string authToken, CancellationToken stoppingToken)
 | |
|   {
 | |
|     try
 | |
|     {
 | |
|       using var scope = _serviceProvider.CreateScope();
 | |
|       var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
 | |
| 
 | |
|       var municipiosASondear = await dbContext.AmbitosGeograficos
 | |
|           .AsNoTracking()
 | |
|           .Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null)
 | |
|           .ToListAsync(stoppingToken);
 | |
| 
 | |
|       var todasLasCategorias = await dbContext.CategoriasElectorales
 | |
|           .AsNoTracking()
 | |
|           .ToListAsync(stoppingToken);
 | |
| 
 | |
|       if (!municipiosASondear.Any() || !todasLasCategorias.Any())
 | |
|       {
 | |
|         _logger.LogWarning("No se encontraron Partidos (NivelId 30) o Categorías para sondear resultados.");
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       _logger.LogInformation("Iniciando sondeo de resultados para {m} municipios y {c} categorías...", municipiosASondear.Count, todasLasCategorias.Count);
 | |
| 
 | |
|       foreach (var municipio in municipiosASondear)
 | |
|       {
 | |
|         if (stoppingToken.IsCancellationRequested) break;
 | |
| 
 | |
|         var tareasCategoria = todasLasCategorias.Select(async categoria =>
 | |
|         {
 | |
|           var resultados = await _apiService.GetResultadosAsync(authToken, municipio.DistritoId!, municipio.SeccionId!, null, categoria.Id);
 | |
| 
 | |
|           if (resultados != null)
 | |
|           {
 | |
|             using var innerScope = _serviceProvider.CreateScope();
 | |
|             var innerDbContext = innerScope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
 | |
| 
 | |
|             // --- LLAMADA CORRECTA ---
 | |
|             await GuardarResultadosDeAmbitoAsync(innerDbContext, municipio.Id, categoria.Id, resultados, stoppingToken);
 | |
|           }
 | |
|         });
 | |
| 
 | |
|         await Task.WhenAll(tareasCategoria);
 | |
|       }
 | |
|     }
 | |
|     catch (Exception ex)
 | |
|     {
 | |
|       _logger.LogError(ex, "Ocurrió un error inesperado durante el sondeo de resultados municipales.");
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private async Task GuardarResultadosDeAmbitoAsync(
 | |
|       EleccionesDbContext dbContext, int ambitoId, int categoriaId,
 | |
|       Elecciones.Core.DTOs.ResultadosDto resultadosDto, CancellationToken stoppingToken)
 | |
|   {
 | |
|     var estadoRecuento = await dbContext.EstadosRecuentos.FindAsync(new object[] { ambitoId, categoriaId }, stoppingToken);
 | |
| 
 | |
|     if (estadoRecuento == null)
 | |
|     {
 | |
|       estadoRecuento = new EstadoRecuento { AmbitoGeograficoId = ambitoId, CategoriaId = categoriaId };
 | |
|       dbContext.EstadosRecuentos.Add(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;
 | |
|     }
 | |
| 
 | |
|     foreach (var votoPositivoDto in resultadosDto.ValoresTotalizadosPositivos)
 | |
|     {
 | |
|       var resultadoVoto = await dbContext.ResultadosVotos.FirstOrDefaultAsync(
 | |
|           rv => rv.AmbitoGeograficoId == ambitoId &&
 | |
|                 rv.CategoriaId == categoriaId &&
 | |
|                 rv.AgrupacionPoliticaId == votoPositivoDto.IdAgrupacion,
 | |
|           stoppingToken
 | |
|       );
 | |
| 
 | |
|       if (resultadoVoto == null)
 | |
|       {
 | |
|         resultadoVoto = new ResultadoVoto
 | |
|         {
 | |
|           AmbitoGeograficoId = ambitoId,
 | |
|           CategoriaId = categoriaId,
 | |
|           AgrupacionPoliticaId = votoPositivoDto.IdAgrupacion
 | |
|         };
 | |
|         dbContext.ResultadosVotos.Add(resultadoVoto);
 | |
|       }
 | |
|       resultadoVoto.CantidadVotos = votoPositivoDto.Votos;
 | |
|       resultadoVoto.PorcentajeVotos = votoPositivoDto.VotosPorcentaje;
 | |
|     }
 | |
| 
 | |
|     try
 | |
|     {
 | |
|       await dbContext.SaveChangesAsync(stoppingToken);
 | |
|     }
 | |
|     catch (DbUpdateException ex)
 | |
|     {
 | |
|       _logger.LogError(ex, "DbUpdateException al guardar resultados para AmbitoId {ambitoId} y CategoriaId {categoriaId}", ambitoId, categoriaId);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /// <summary>
 | |
|   /// Obtiene y actualiza el resumen de votos y el estado del recuento a nivel provincial.
 | |
|   /// Esta versión actualizada guarda tanto los votos por agrupación (en ResumenesVotos)
 | |
|   /// como el estado general del recuento, incluyendo la fecha de totalización (en EstadosRecuentosGenerales),
 | |
|   /// asegurando que toda la operación sea atómica mediante una transacción de 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 SondearResumenProvincialAsync(string authToken, CancellationToken stoppingToken)
 | |
|   {
 | |
|     try
 | |
|     {
 | |
|       // Creamos un scope de DbContext para esta operación.
 | |
|       using var scope = _serviceProvider.CreateScope();
 | |
|       var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
 | |
| 
 | |
|       // Obtenemos el registro de la Provincia (NivelId 10).
 | |
|       var provincia = await dbContext.AmbitosGeograficos
 | |
|           .AsNoTracking()
 | |
|           .FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken);
 | |
| 
 | |
|       // Si no encontramos el ámbito de la provincia, no podemos continuar.
 | |
|       if (provincia == null)
 | |
|       {
 | |
|         _logger.LogWarning("No se encontró el ámbito 'Provincia' (NivelId 10) para el sondeo de resumen.");
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // Llamamos a la API para obtener el resumen de datos provincial.
 | |
|       var resumenDto = await _apiService.GetResumenAsync(authToken, provincia.DistritoId!);
 | |
| 
 | |
|       // Solo procedemos si la API devolvió una respuesta válida y no nula.
 | |
|       if (resumenDto != null)
 | |
|       {
 | |
|         // Iniciamos una transacción explícita. Esto garantiza que todas las operaciones de base de datos
 | |
|         // dentro de este bloque (el DELETE, los INSERTs y los UPDATEs) se completen con éxito,
 | |
|         // o si algo falla, se reviertan todas, manteniendo la consistencia de los datos.
 | |
|         await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken);
 | |
| 
 | |
|         // --- 1. ACTUALIZAR LA TABLA 'ResumenesVotos' ---
 | |
| 
 | |
|         // Verificamos si la respuesta contiene una lista de votos positivos.
 | |
|         if (resumenDto.ValoresTotalizadosPositivos is { Count: > 0 } nuevosVotos)
 | |
|         {
 | |
|           // Estrategia "Borrar y Reemplazar": vaciamos la tabla antes de insertar los nuevos datos.
 | |
|           await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ResumenesVotos", stoppingToken);
 | |
| 
 | |
|           // Añadimos cada nuevo registro de voto al DbContext.
 | |
|           foreach (var voto in nuevosVotos)
 | |
|           {
 | |
|             dbContext.ResumenesVotos.Add(new ResumenVoto
 | |
|             {
 | |
|               AmbitoGeograficoId = provincia.Id,
 | |
|               AgrupacionPoliticaId = voto.IdAgrupacion,
 | |
|               Votos = voto.Votos,
 | |
|               VotosPorcentaje = voto.VotosPorcentaje
 | |
|             });
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         // --- 2. ACTUALIZAR LA TABLA 'EstadosRecuentosGenerales' ---
 | |
| 
 | |
|         // El endpoint de Resumen no especifica una categoría, por lo que aplicamos sus datos de estado de recuento
 | |
|         // a todas las categorías que tenemos en nuestra base de datos.
 | |
|         var todasLasCategorias = await dbContext.CategoriasElectorales.AsNoTracking().ToListAsync(stoppingToken);
 | |
|         foreach (var categoria in todasLasCategorias)
 | |
|         {
 | |
|           // Buscamos el registro existente usando la clave primaria compuesta.
 | |
|           var registroDb = await dbContext.EstadosRecuentosGenerales.FindAsync(new object[] { provincia.Id, categoria.Id }, stoppingToken);
 | |
| 
 | |
|           // Si no existe, lo creamos.
 | |
|           if (registroDb == null)
 | |
|           {
 | |
|             registroDb = new EstadoRecuentoGeneral { AmbitoGeograficoId = provincia.Id, CategoriaId = categoria.Id };
 | |
|             dbContext.EstadosRecuentosGenerales.Add(registroDb);
 | |
|           }
 | |
| 
 | |
|           // Parseamos la fecha de forma segura para evitar errores con cadenas vacías o nulas.
 | |
|           if (DateTime.TryParse(resumenDto.FechaTotalizacion, out var parsedDate))
 | |
|           {
 | |
|             registroDb.FechaTotalizacion = parsedDate.ToUniversalTime();
 | |
|           }
 | |
| 
 | |
|           // Mapeamos el resto de los datos del estado del recuento.
 | |
|           registroDb.MesasEsperadas = resumenDto.EstadoRecuento.MesasEsperadas;
 | |
|           registroDb.MesasTotalizadas = resumenDto.EstadoRecuento.MesasTotalizadas;
 | |
|           registroDb.MesasTotalizadasPorcentaje = resumenDto.EstadoRecuento.MesasTotalizadasPorcentaje;
 | |
|           registroDb.CantidadElectores = resumenDto.EstadoRecuento.CantidadElectores;
 | |
|           registroDb.CantidadVotantes = resumenDto.EstadoRecuento.CantidadVotantes;
 | |
|           registroDb.ParticipacionPorcentaje = resumenDto.EstadoRecuento.ParticipacionPorcentaje;
 | |
|         }
 | |
| 
 | |
|         // 3. CONFIRMAR Y GUARDAR
 | |
|         // Guardamos todos los cambios preparados (DELETEs, INSERTs, UPDATEs) en la base de datos.
 | |
|         await dbContext.SaveChangesAsync(stoppingToken);
 | |
|         // Confirmamos la transacción para hacer los cambios permanentes.
 | |
|         await transaction.CommitAsync(stoppingToken);
 | |
| 
 | |
|         _logger.LogInformation("Sondeo de Resumen Provincial completado. Las tablas han sido actualizadas.");
 | |
|       }
 | |
|       else
 | |
|       {
 | |
|         // Si la API no devolvió datos (ej. devuelve null), no hacemos nada en la BD.
 | |
|         _logger.LogInformation("Sondeo de Resumen Provincial completado. No se recibieron datos nuevos.");
 | |
|       }
 | |
|     }
 | |
|     catch (OperationCanceledException)
 | |
|     {
 | |
|       _logger.LogInformation("Sondeo de resumen provincial cancelado.");
 | |
|     }
 | |
|     catch (Exception ex)
 | |
|     {
 | |
|       // Capturamos cualquier otro error inesperado para que el worker no se detenga.
 | |
|       _logger.LogError(ex, "Ocurrió un error CRÍTICO 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.");
 | |
|     }
 | |
|   }
 | |
| }
 |