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.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68dce9415e165633856e4fae9b2d71cc07b4e2ff")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+19b37f73206d043982fc77f8c2359f2598889b64")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Infrastructure")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Infrastructure")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
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.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();
|
||||
|
||||
|
||||
@@ -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.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68dce9415e165633856e4fae9b2d71cc07b4e2ff")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+19b37f73206d043982fc77f8c2359f2598889b64")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Worker")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Worker")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
Reference in New Issue
Block a user