Try Separación de Metodos
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Elecciones.Infrastructure.Services;
|
||||||
|
|
||||||
|
public class SharedTokenService
|
||||||
|
{
|
||||||
|
private readonly IElectoralApiService _apiService;
|
||||||
|
private readonly ILogger<SharedTokenService> _logger;
|
||||||
|
private string? _authToken;
|
||||||
|
private DateTimeOffset _tokenExpiration = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
// Un SemaphoreSlim para asegurar que solo una tarea a la vez intente renovar el token.
|
||||||
|
private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1);
|
||||||
|
|
||||||
|
public SharedTokenService(IElectoralApiService apiService, ILogger<SharedTokenService> logger)
|
||||||
|
{
|
||||||
|
_apiService = apiService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetValidAuthTokenAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// Si el token es válido, lo devolvemos inmediatamente sin bloquear.
|
||||||
|
if (!string.IsNullOrEmpty(_authToken) && DateTimeOffset.UtcNow < _tokenExpiration.AddMinutes(-1))
|
||||||
|
{
|
||||||
|
return _authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si el token necesita renovación, esperamos nuestro turno para intentar renovarlo.
|
||||||
|
await _tokenSemaphore.WaitAsync(stoppingToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Volvemos a comprobar por si otra tarea ya lo renovó mientras esperábamos.
|
||||||
|
if (!string.IsNullOrEmpty(_authToken) && DateTimeOffset.UtcNow < _tokenExpiration.AddMinutes(-1))
|
||||||
|
{
|
||||||
|
return _authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Token no válido o a punto de expirar. Solicitando uno nuevo...");
|
||||||
|
var tokenResponse = await _apiService.GetAuthTokenAsync();
|
||||||
|
|
||||||
|
if (tokenResponse?.Data?.AccessToken != null)
|
||||||
|
{
|
||||||
|
_authToken = tokenResponse.Data.AccessToken;
|
||||||
|
_tokenExpiration = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.Data.ExpiresIn);
|
||||||
|
_logger.LogInformation("Nuevo token obtenido. Válido hasta: {expiration}", _tokenExpiration);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("CRÍTICO: No se pudo obtener un nuevo token de autenticación.");
|
||||||
|
_authToken = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_tokenSemaphore.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _authToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Infrastructure")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Infrastructure")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68dce9415e165633856e4fae9b2d71cc07b4e2ff")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+19b37f73206d043982fc77f8c2359f2598889b64")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Infrastructure")]
|
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Infrastructure")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Infrastructure")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Infrastructure")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
430
Elecciones-Web/src/Elecciones.Worker/CriticalDataWorker.cs
Normal file
430
Elecciones-Web/src/Elecciones.Worker/CriticalDataWorker.cs
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
using Elecciones.Database;
|
||||||
|
using Elecciones.Database.Entities;
|
||||||
|
using Elecciones.Infrastructure.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace Elecciones.Worker;
|
||||||
|
|
||||||
|
public class CriticalDataWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<CriticalDataWorker> _logger;
|
||||||
|
private readonly SharedTokenService _tokenService;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly IElectoralApiService _apiService; // <-- DEPENDENCIA AÑADIDA
|
||||||
|
|
||||||
|
// Inyectamos IElectoralApiService en el constructor
|
||||||
|
public CriticalDataWorker(
|
||||||
|
ILogger<CriticalDataWorker> logger,
|
||||||
|
SharedTokenService tokenService,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
IElectoralApiService apiService) // <-- PARÁMETRO AÑADIDO
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_tokenService = tokenService;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_apiService = apiService; // <-- ASIGNACIÓN AÑADIDA
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Worker de Datos Críticos iniciado.");
|
||||||
|
|
||||||
|
// Damos tiempo a la sincronización inicial del otro worker para que se complete.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) { return; } // Salir si la app se apaga durante la espera inicial
|
||||||
|
|
||||||
|
int cicloContador = 0;
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var cicloInicio = DateTime.UtcNow;
|
||||||
|
cicloContador++;
|
||||||
|
|
||||||
|
_logger.LogInformation("--- Iniciando Ciclo de Datos Críticos #{ciclo} ---", cicloContador);
|
||||||
|
|
||||||
|
var authToken = await _tokenService.GetValidAuthTokenAsync(stoppingToken);
|
||||||
|
if (string.IsNullOrEmpty(authToken))
|
||||||
|
{
|
||||||
|
_logger.LogError("Ciclo Crítico: No se pudo obtener token. Reintentando en 30 segundos.");
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SondearResultadosMunicipalesAsync(authToken, stoppingToken);
|
||||||
|
await SondearResumenProvincialAsync(authToken, stoppingToken);
|
||||||
|
await SondearEstadoRecuentoGeneralAsync(authToken, stoppingToken);
|
||||||
|
|
||||||
|
var cicloFin = DateTime.UtcNow;
|
||||||
|
var duracionCiclo = cicloFin - cicloInicio;
|
||||||
|
_logger.LogInformation("--- Ciclo de Datos Críticos #{ciclo} completado en {duration:N2} segundos. ---", cicloContador, duracionCiclo.TotalSeconds);
|
||||||
|
|
||||||
|
var tiempoDeEspera = TimeSpan.FromSeconds(30) - duracionCiclo;
|
||||||
|
if (tiempoDeEspera < TimeSpan.Zero) tiempoDeEspera = TimeSpan.Zero;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(tiempoDeEspera, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sondea los resultados electorales para todos los municipios/partidos de forma optimizada.
|
||||||
|
/// Utiliza paralelismo controlado para ejecutar múltiples peticiones a la API simultáneamente
|
||||||
|
/// sin sobrecargar la red, y luego guarda todos los resultados en la base de datos de forma masiva.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authToken">El token de autenticación válido para la sesión.</param>
|
||||||
|
/// <param name="stoppingToken">El token de cancelación para detener la operación.</param>
|
||||||
|
private async Task SondearResultadosMunicipalesAsync(string authToken, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// PASO 1: Preparar el DbContext y los datos necesarios.
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
||||||
|
|
||||||
|
// Obtenemos de nuestra BD local la lista de todos los partidos (NivelId=30) que necesitamos consultar.
|
||||||
|
var municipiosASondear = await dbContext.AmbitosGeograficos
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null)
|
||||||
|
.Select(a => new { a.Id, a.Nombre, a.MunicipioId, a.SeccionId, a.DistritoId })
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (!municipiosASondear.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No se encontraron Partidos (NivelId 30) en la BD para sondear resultados.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtenemos la categoría "CONCEJALES", ya que los resultados municipales aplican a esta.
|
||||||
|
var categoriaConcejales = await dbContext.CategoriasElectorales
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(c => c.Nombre.Contains("CONCEJALES"), stoppingToken);
|
||||||
|
|
||||||
|
if (categoriaConcejales == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No se encontró la categoría 'CONCEJALES'. Omitiendo sondeo de resultados municipales.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASO 2: Ejecutar las consultas a la API con paralelismo controlado.
|
||||||
|
|
||||||
|
// Definimos cuántas peticiones queremos que se ejecuten simultáneamente.
|
||||||
|
// Un valor entre 8 y 16 es generalmente seguro y ofrece una gran mejora de velocidad.
|
||||||
|
const int GRADO_DE_PARALELISMO = 3;
|
||||||
|
// Creamos un semáforo que actuará como un "control de acceso" con 10 pases libres.
|
||||||
|
var semaforo = new SemaphoreSlim(GRADO_DE_PARALELISMO);
|
||||||
|
|
||||||
|
// Usamos un ConcurrentDictionary para almacenar los resultados. A diferencia de un Dictionary normal,
|
||||||
|
// este permite que múltiples tareas escriban en él al mismo tiempo sin conflictos.
|
||||||
|
var resultadosPorId = new ConcurrentDictionary<int, Elecciones.Core.DTOs.ResultadosDto>();
|
||||||
|
|
||||||
|
_logger.LogInformation("Iniciando sondeo de resultados para {count} municipios con un paralelismo de {degree}...", municipiosASondear.Count, GRADO_DE_PARALELISMO);
|
||||||
|
|
||||||
|
// Creamos una lista de tareas (Tasks), una por cada municipio a consultar.
|
||||||
|
// El método .Select() no ejecuta las tareas todavía, solo las prepara.
|
||||||
|
var tareas = municipiosASondear.Select(async municipio =>
|
||||||
|
{
|
||||||
|
// Cada tarea debe "pedir permiso" al semáforo antes de ejecutarse.
|
||||||
|
// Si ya hay 10 tareas en ejecución, esta línea esperará hasta que una termine.
|
||||||
|
await semaforo.WaitAsync(stoppingToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Una vez que obtiene el permiso, ejecuta la petición a la API.
|
||||||
|
var resultados = await _apiService.GetResultadosAsync(
|
||||||
|
authToken, municipio.DistritoId!, municipio.SeccionId!, null, categoriaConcejales.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si la API devuelve datos válidos...
|
||||||
|
if (resultados != null)
|
||||||
|
{
|
||||||
|
// ...los guardamos en el diccionario concurrente.
|
||||||
|
resultadosPorId[municipio.Id] = resultados;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// ¡CRUCIAL! Liberamos el pase del semáforo, permitiendo que la siguiente
|
||||||
|
// tarea en espera pueda comenzar su ejecución.
|
||||||
|
semaforo.Release();
|
||||||
|
// Añadir un pequeño retraso aleatorio para no parecer un robot
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(new Random().Next(50, 251)), stoppingToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ahora sí, ejecutamos todas las tareas preparadas en paralelo y esperamos a que todas terminen.
|
||||||
|
await Task.WhenAll(tareas);
|
||||||
|
|
||||||
|
// PASO 3: Guardar los resultados en la base de datos.
|
||||||
|
// Solo procedemos si recolectamos al menos un resultado válido.
|
||||||
|
if (resultadosPorId.Any())
|
||||||
|
{
|
||||||
|
// Llamamos a nuestro método de guardado masivo y optimizado, pasándole todos los resultados
|
||||||
|
// recolectados para que los inserte en una única y eficiente transacción.
|
||||||
|
await GuardarResultadosDeMunicipiosAsync(dbContext, resultadosPorId.ToDictionary(kv => kv.Key, kv => kv.Value), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Capturamos cualquier error inesperado en el proceso para que el worker no se detenga.
|
||||||
|
_logger.LogError(ex, "Ocurrió un error inesperado durante el sondeo de resultados municipales.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Realiza una operación "Upsert" (Update o Insert) de forma masiva y optimizada.
|
||||||
|
/// Este método es llamado por SondearResultadosMunicipalesAsync.
|
||||||
|
/// </summary>
|
||||||
|
private async Task GuardarResultadosDeMunicipiosAsync(
|
||||||
|
EleccionesDbContext dbContext,
|
||||||
|
Dictionary<int, Elecciones.Core.DTOs.ResultadosDto> todosLosResultados,
|
||||||
|
CancellationToken stoppingToken) // <-- PARÁMETRO AÑADIDO
|
||||||
|
{
|
||||||
|
// Obtenemos los IDs de todos los ámbitos que vamos a actualizar.
|
||||||
|
var ambitoIds = todosLosResultados.Keys;
|
||||||
|
|
||||||
|
// --- OPTIMIZACIÓN 1: Cargar todos los datos existentes en memoria UNA SOLA VEZ ---
|
||||||
|
var estadosRecuentoExistentes = await dbContext.EstadosRecuentos
|
||||||
|
.Where(e => ambitoIds.Contains(e.AmbitoGeograficoId))
|
||||||
|
.ToDictionaryAsync(e => e.AmbitoGeograficoId, stoppingToken);
|
||||||
|
|
||||||
|
var resultadosVotosExistentes = await dbContext.ResultadosVotos
|
||||||
|
.Where(rv => ambitoIds.Contains(rv.AmbitoGeograficoId))
|
||||||
|
.GroupBy(rv => rv.AmbitoGeograficoId)
|
||||||
|
.ToDictionaryAsync(g => g.Key, g => g.ToDictionary(item => item.AgrupacionPoliticaId), stoppingToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("Procesando en memoria los resultados de {count} municipios.", todosLosResultados.Count);
|
||||||
|
|
||||||
|
// --- OPTIMIZACIÓN 2: Procesar todo en memoria ---
|
||||||
|
foreach (var kvp in todosLosResultados)
|
||||||
|
{
|
||||||
|
var ambitoId = kvp.Key;
|
||||||
|
var resultadosDto = kvp.Value;
|
||||||
|
|
||||||
|
// Lógica Upsert para EstadoRecuento
|
||||||
|
if (!estadosRecuentoExistentes.TryGetValue(ambitoId, out var estadoRecuento))
|
||||||
|
{
|
||||||
|
estadoRecuento = new EstadoRecuento { AmbitoGeograficoId = ambitoId };
|
||||||
|
dbContext.EstadosRecuentos.Add(estadoRecuento);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapeo completo de propiedades para EstadoRecuento
|
||||||
|
estadoRecuento.FechaTotalizacion = DateTime.Parse(resultadosDto.FechaTotalizacion).ToUniversalTime();
|
||||||
|
estadoRecuento.MesasEsperadas = resultadosDto.EstadoRecuento.MesasEsperadas;
|
||||||
|
estadoRecuento.MesasTotalizadas = resultadosDto.EstadoRecuento.MesasTotalizadas;
|
||||||
|
estadoRecuento.MesasTotalizadasPorcentaje = resultadosDto.EstadoRecuento.MesasTotalizadasPorcentaje;
|
||||||
|
estadoRecuento.CantidadElectores = resultadosDto.EstadoRecuento.CantidadElectores;
|
||||||
|
estadoRecuento.CantidadVotantes = resultadosDto.EstadoRecuento.CantidadVotantes;
|
||||||
|
estadoRecuento.ParticipacionPorcentaje = resultadosDto.EstadoRecuento.ParticipacionPorcentaje;
|
||||||
|
|
||||||
|
if (resultadosDto.ValoresTotalizadosOtros != null)
|
||||||
|
{
|
||||||
|
estadoRecuento.VotosEnBlanco = resultadosDto.ValoresTotalizadosOtros.VotosEnBlanco;
|
||||||
|
estadoRecuento.VotosEnBlancoPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosEnBlancoPorcentaje;
|
||||||
|
estadoRecuento.VotosNulos = resultadosDto.ValoresTotalizadosOtros.VotosNulos;
|
||||||
|
estadoRecuento.VotosNulosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosNulosPorcentaje;
|
||||||
|
estadoRecuento.VotosRecurridos = resultadosDto.ValoresTotalizadosOtros.VotosRecurridos;
|
||||||
|
estadoRecuento.VotosRecurridosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosRecurridosPorcentaje;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lógica Upsert para ResultadosVotos
|
||||||
|
var votosDeAmbitoExistentes = resultadosVotosExistentes.GetValueOrDefault(ambitoId);
|
||||||
|
foreach (var votoPositivoDto in resultadosDto.ValoresTotalizadosPositivos)
|
||||||
|
{
|
||||||
|
ResultadoVoto? resultadoVoto = null;
|
||||||
|
if (votosDeAmbitoExistentes != null)
|
||||||
|
{
|
||||||
|
votosDeAmbitoExistentes.TryGetValue(votoPositivoDto.IdAgrupacion, out resultadoVoto);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultadoVoto == null)
|
||||||
|
{
|
||||||
|
resultadoVoto = new ResultadoVoto
|
||||||
|
{
|
||||||
|
AmbitoGeograficoId = ambitoId,
|
||||||
|
AgrupacionPoliticaId = votoPositivoDto.IdAgrupacion
|
||||||
|
};
|
||||||
|
dbContext.ResultadosVotos.Add(resultadoVoto);
|
||||||
|
}
|
||||||
|
resultadoVoto.CantidadVotos = votoPositivoDto.Votos;
|
||||||
|
resultadoVoto.PorcentajeVotos = votoPositivoDto.VotosPorcentaje;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OPTIMIZACIÓN 3: Guardar todos los cambios en UNA SOLA TRANSACCIÓN ---
|
||||||
|
_logger.LogInformation("Guardando todos los cambios de resultados municipales en la base de datos...");
|
||||||
|
// Ahora 'stoppingToken' es reconocido aquí
|
||||||
|
await dbContext.SaveChangesAsync(stoppingToken);
|
||||||
|
_logger.LogInformation("Guardado completado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Obtiene y actualiza el resumen de votos a nivel provincial.
|
||||||
|
/// Esta versión mejorada utiliza una transacción para garantizar la consistencia de los datos.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SondearResumenProvincialAsync(string authToken, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
||||||
|
|
||||||
|
var provincia = await dbContext.AmbitosGeograficos.AsNoTracking().FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken);
|
||||||
|
if (provincia == null) return;
|
||||||
|
|
||||||
|
var resumen = await _apiService.GetResumenAsync(authToken, provincia.DistritoId!);
|
||||||
|
|
||||||
|
// --- CAMBIO CLAVE: Lógica de actualización robusta ---
|
||||||
|
// Solo procedemos si la respuesta de la API es válida Y contiene datos de votos positivos.
|
||||||
|
if (resumen?.ValoresTotalizadosPositivos is { Count: > 0 } nuevosVotos)
|
||||||
|
{
|
||||||
|
// Usamos una transacción explícita para asegurar que la operación sea atómica:
|
||||||
|
// O se completa todo (borrado e inserción), o no se hace nada.
|
||||||
|
await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken);
|
||||||
|
|
||||||
|
// 1. Borramos los datos viejos.
|
||||||
|
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ResumenesVotos", stoppingToken);
|
||||||
|
|
||||||
|
// 2. Insertamos los nuevos datos.
|
||||||
|
foreach (var voto in nuevosVotos)
|
||||||
|
{
|
||||||
|
dbContext.ResumenesVotos.Add(new ResumenVoto
|
||||||
|
{
|
||||||
|
AmbitoGeograficoId = provincia.Id,
|
||||||
|
AgrupacionPoliticaId = voto.IdAgrupacion,
|
||||||
|
Votos = voto.Votos,
|
||||||
|
VotosPorcentaje = voto.VotosPorcentaje
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Guardamos los cambios y confirmamos la transacción.
|
||||||
|
await dbContext.SaveChangesAsync(stoppingToken);
|
||||||
|
await transaction.CommitAsync(stoppingToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("Sondeo de Resumen Provincial completado. La tabla ha sido actualizada.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Si la API no devuelve datos de votos, no hacemos NADA en la base de datos.
|
||||||
|
_logger.LogInformation("Sondeo de Resumen Provincial completado. No se recibieron datos nuevos, la tabla no fue modificada.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Ocurrió un error en el sondeo de Resumen Provincial.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Obtiene y actualiza el estado general del recuento a nivel provincial para CADA categoría electoral.
|
||||||
|
/// Esta versión es robusta: consulta dinámicamente las categorías, usa la clave primaria compuesta
|
||||||
|
/// de la base de datos y guarda todos los cambios en una única transacción al final.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authToken">El token de autenticación válido para la sesión.</param>
|
||||||
|
/// <param name="stoppingToken">El token de cancelación para detener la operación.</param>
|
||||||
|
private async Task SondearEstadoRecuentoGeneralAsync(string authToken, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// PASO 1: Crear un "scope" para obtener una instancia fresca de DbContext.
|
||||||
|
// Esto es una práctica recomendada para servicios de larga duración para evitar problemas de concurrencia.
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
||||||
|
|
||||||
|
// PASO 2: Obtener el ámbito geográfico de la Provincia.
|
||||||
|
// Necesitamos este objeto para obtener su 'DistritoId' ("02"), que es requerido por la API.
|
||||||
|
var provincia = await dbContext.AmbitosGeograficos
|
||||||
|
.AsNoTracking() // Optimización: Solo necesitamos leer datos, no modificarlos.
|
||||||
|
.FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken);
|
||||||
|
|
||||||
|
// Comprobación de seguridad: Si la sincronización inicial falló y no tenemos el registro de la provincia,
|
||||||
|
// no podemos continuar. Registramos una advertencia y salimos del método.
|
||||||
|
if (provincia == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No se encontró el ámbito 'Provincia' (NivelId 10) en la BD. Omitiendo sondeo de estado general.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASO 3: Obtener todas las categorías electorales disponibles desde nuestra base de datos.
|
||||||
|
// Esto hace que el método sea dinámico y no dependa de IDs fijos en el código.
|
||||||
|
var categoriasParaSondear = await dbContext.CategoriasElectorales
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (!categoriasParaSondear.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No hay categorías en la BD para sondear el estado general del recuento.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Iniciando sondeo de Estado Recuento General para {count} categorías...", categoriasParaSondear.Count);
|
||||||
|
|
||||||
|
// PASO 4: Iterar sobre cada categoría para obtener su estado de recuento individual.
|
||||||
|
foreach (var categoria in categoriasParaSondear)
|
||||||
|
{
|
||||||
|
// Salimos limpiamente del bucle si la aplicación se está deteniendo.
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
// Llamamos a la API con el distrito y la CATEGORÍA ACTUAL del bucle.
|
||||||
|
var estadoDto = await _apiService.GetEstadoRecuentoGeneralAsync(authToken, provincia.DistritoId!, categoria.Id);
|
||||||
|
|
||||||
|
// Solo procedemos si la API devolvió datos válidos.
|
||||||
|
if (estadoDto != null)
|
||||||
|
{
|
||||||
|
// Lógica "Upsert" (Update or Insert):
|
||||||
|
// Buscamos un registro existente usando la CLAVE PRIMARIA COMPUESTA.
|
||||||
|
var registroDb = await dbContext.EstadosRecuentosGenerales.FindAsync(
|
||||||
|
new object[] { provincia.Id, categoria.Id },
|
||||||
|
cancellationToken: stoppingToken
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si no se encuentra (FindAsync devuelve null), es un registro nuevo.
|
||||||
|
if (registroDb == null)
|
||||||
|
{
|
||||||
|
// Creamos una nueva instancia de la entidad.
|
||||||
|
registroDb = new EstadoRecuentoGeneral
|
||||||
|
{
|
||||||
|
AmbitoGeograficoId = provincia.Id,
|
||||||
|
CategoriaId = categoria.Id // Asignamos ambas partes de la clave primaria.
|
||||||
|
};
|
||||||
|
// Y la añadimos al ChangeTracker de EF para que la inserte en la BD.
|
||||||
|
dbContext.EstadosRecuentosGenerales.Add(registroDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapeamos los datos del DTO de la API a nuestra entidad de base de datos.
|
||||||
|
// Esto se hace tanto para registros nuevos como para los existentes que se van a actualizar.
|
||||||
|
registroDb.MesasEsperadas = estadoDto.MesasEsperadas;
|
||||||
|
registroDb.MesasTotalizadas = estadoDto.MesasTotalizadas;
|
||||||
|
registroDb.MesasTotalizadasPorcentaje = estadoDto.MesasTotalizadasPorcentaje;
|
||||||
|
registroDb.CantidadElectores = estadoDto.CantidadElectores;
|
||||||
|
registroDb.CantidadVotantes = estadoDto.CantidadVotantes;
|
||||||
|
registroDb.ParticipacionPorcentaje = estadoDto.ParticipacionPorcentaje;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASO 5: Guardar todos los cambios en la base de datos.
|
||||||
|
// Al llamar a SaveChangesAsync UNA SOLA VEZ fuera del bucle, EF Core agrupa
|
||||||
|
// todas las inserciones y actualizaciones en una única transacción eficiente.
|
||||||
|
await dbContext.SaveChangesAsync(stoppingToken);
|
||||||
|
_logger.LogInformation("Sondeo de Estado Recuento General completado para todas las categorías.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Capturamos cualquier excepción inesperada para que no detenga el worker y la registramos.
|
||||||
|
_logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Estado Recuento General.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pega aquí los métodos:
|
||||||
|
// - SondearResultadosMunicipalesAsync
|
||||||
|
// - GuardarResultadosDeMunicipiosAsync
|
||||||
|
// - SondearResumenProvincialAsync
|
||||||
|
// - SondearEstadoRecuentoGeneralAsync
|
||||||
|
// (Estos métodos necesitan IServiceProvider y SharedTokenService, que ya están inyectados)
|
||||||
|
}
|
||||||
433
Elecciones-Web/src/Elecciones.Worker/LowPriorityDataWorker.cs
Normal file
433
Elecciones-Web/src/Elecciones.Worker/LowPriorityDataWorker.cs
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
using Elecciones.Database;
|
||||||
|
using Elecciones.Database.Entities;
|
||||||
|
using Elecciones.Infrastructure.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Elecciones.Worker;
|
||||||
|
|
||||||
|
public class LowPriorityDataWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<LowPriorityDataWorker> _logger;
|
||||||
|
private readonly SharedTokenService _tokenService;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly IElectoralApiService _apiService;
|
||||||
|
|
||||||
|
public LowPriorityDataWorker(
|
||||||
|
ILogger<LowPriorityDataWorker> logger,
|
||||||
|
SharedTokenService tokenService,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
IElectoralApiService apiService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_tokenService = tokenService;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_apiService = apiService;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Worker de Baja Prioridad iniciado.");
|
||||||
|
|
||||||
|
await SincronizarCatalogosMaestrosAsync(stoppingToken);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("--- Iniciando Ciclo de Datos de Baja Prioridad ---");
|
||||||
|
|
||||||
|
var authToken = await _tokenService.GetValidAuthTokenAsync(stoppingToken);
|
||||||
|
if (string.IsNullOrEmpty(authToken))
|
||||||
|
{
|
||||||
|
_logger.LogError("Ciclo de Baja Prioridad: No se pudo obtener token. Reintentando en 1 minuto.");
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SondearProyeccionBancasAsync(authToken, stoppingToken);
|
||||||
|
await SondearNuevosTelegramasAsync(authToken, stoppingToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("--- Ciclo de Datos de Baja Prioridad completado. Esperando 5 minutos. ---");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Descarga y sincroniza los catálogos base (Categorías, Ámbitos, Agrupaciones)
|
||||||
|
/// desde la API a la base de datos local. Se ejecuta una sola vez al iniciar el worker.
|
||||||
|
/// Utiliza una estrategia de guardado en lotes para manejar grandes volúmenes de datos
|
||||||
|
/// sin sobrecargar la base de datos.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stoppingToken">El token de cancelación para detener la operación.</param>
|
||||||
|
private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Iniciando sincronización de catálogos maestros...");
|
||||||
|
|
||||||
|
// --- CORRECCIÓN: Usar el _tokenService inyectado ---
|
||||||
|
var authToken = await _tokenService.GetValidAuthTokenAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(authToken) || stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogError("No se pudo obtener token para la sincronización de catálogos. La operación se cancela.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creamos un scope de servicios para obtener una instancia fresca de DbContext.
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
||||||
|
|
||||||
|
// PASO 2: Sincronizar las categorías electorales.
|
||||||
|
// Es un catálogo pequeño y es la base para las siguientes consultas.
|
||||||
|
var categoriasApi = await _apiService.GetCategoriasAsync(authToken);
|
||||||
|
if (categoriasApi is null || !categoriasApi.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("La API no devolvió datos para el catálogo de Categorías. La sincronización no puede continuar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var distinctCategorias = categoriasApi.GroupBy(c => c.CategoriaId).Select(g => g.First()).OrderBy(c => c.Orden).ToList();
|
||||||
|
_logger.LogInformation("Se procesarán {count} categorías electorales.", distinctCategorias.Count);
|
||||||
|
|
||||||
|
var categoriasEnDb = await dbContext.CategoriasElectorales.ToDictionaryAsync(c => c.Id, c => c, stoppingToken);
|
||||||
|
foreach (var categoriaDto in distinctCategorias)
|
||||||
|
{
|
||||||
|
if (!categoriasEnDb.ContainsKey(categoriaDto.CategoriaId))
|
||||||
|
{
|
||||||
|
dbContext.CategoriasElectorales.Add(new CategoriaElectoral { Id = categoriaDto.CategoriaId, Nombre = categoriaDto.Nombre, Orden = categoriaDto.Orden });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Guardamos las categorías primero para asegurar su existencia.
|
||||||
|
await dbContext.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
// PASO 3: Cargar los catálogos existentes en memoria para una comparación eficiente.
|
||||||
|
// Esto evita hacer miles de consultas a la BD dentro de un bucle.
|
||||||
|
|
||||||
|
// Para los ámbitos, creamos una clave única robusta que funciona incluso con campos nulos.
|
||||||
|
var ambitosEnDb = new Dictionary<string, AmbitoGeografico>();
|
||||||
|
var todosLosAmbitos = await dbContext.AmbitosGeograficos.ToListAsync(stoppingToken);
|
||||||
|
foreach (var ambito in todosLosAmbitos)
|
||||||
|
{
|
||||||
|
string clave = $"{ambito.NivelId}|{ambito.DistritoId}|{ambito.SeccionProvincialId}|{ambito.SeccionId}|{ambito.MunicipioId}|{ambito.CircuitoId}|{ambito.EstablecimientoId}|{ambito.MesaId}";
|
||||||
|
if (!ambitosEnDb.ContainsKey(clave))
|
||||||
|
{
|
||||||
|
ambitosEnDb.Add(clave, ambito);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var agrupacionesEnDb = await dbContext.AgrupacionesPoliticas.ToDictionaryAsync(a => a.Id, a => a, stoppingToken);
|
||||||
|
|
||||||
|
// Variable para llevar la cuenta del total de registros insertados.
|
||||||
|
int totalCambiosGuardados = 0;
|
||||||
|
|
||||||
|
// PASO 4: Iterar sobre cada categoría para sincronizar sus ámbitos y agrupaciones.
|
||||||
|
foreach (var categoria in distinctCategorias)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
_logger.LogInformation("--- Sincronizando datos para la categoría: {Nombre} (ID: {Id}) ---", categoria.Nombre, categoria.CategoriaId);
|
||||||
|
|
||||||
|
var catalogoDto = await _apiService.GetCatalogoAmbitosAsync(authToken, categoria.CategoriaId);
|
||||||
|
if (catalogoDto != null)
|
||||||
|
{
|
||||||
|
// 4.1 - Procesar y añadir ÁMBITOS nuevos al DbContext
|
||||||
|
foreach (var ambitoDto in catalogoDto.Ambitos)
|
||||||
|
{
|
||||||
|
string claveUnica = $"{ambitoDto.NivelId}|{ambitoDto.CodigoAmbitos.DistritoId}|{ambitoDto.CodigoAmbitos.SeccionProvincialId}|{ambitoDto.CodigoAmbitos.SeccionId}|{ambitoDto.CodigoAmbitos.MunicipioId}|{ambitoDto.CodigoAmbitos.CircuitoId}|{ambitoDto.CodigoAmbitos.EstablecimientoId}|{ambitoDto.CodigoAmbitos.MesaId}";
|
||||||
|
|
||||||
|
if (!ambitosEnDb.ContainsKey(claveUnica))
|
||||||
|
{
|
||||||
|
var nuevoAmbito = new AmbitoGeografico
|
||||||
|
{
|
||||||
|
Nombre = ambitoDto.Nombre,
|
||||||
|
NivelId = ambitoDto.NivelId,
|
||||||
|
DistritoId = ambitoDto.CodigoAmbitos.DistritoId,
|
||||||
|
SeccionProvincialId = ambitoDto.CodigoAmbitos.SeccionProvincialId,
|
||||||
|
SeccionId = ambitoDto.CodigoAmbitos.SeccionId,
|
||||||
|
MunicipioId = ambitoDto.CodigoAmbitos.MunicipioId,
|
||||||
|
CircuitoId = ambitoDto.CodigoAmbitos.CircuitoId,
|
||||||
|
EstablecimientoId = ambitoDto.CodigoAmbitos.EstablecimientoId,
|
||||||
|
MesaId = ambitoDto.CodigoAmbitos.MesaId,
|
||||||
|
};
|
||||||
|
dbContext.AmbitosGeograficos.Add(nuevoAmbito);
|
||||||
|
ambitosEnDb.Add(claveUnica, nuevoAmbito); // Añadir también al diccionario en memoria
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4.2 - Procesar y añadir AGRUPACIONES nuevas al DbContext
|
||||||
|
var provincia = catalogoDto.Ambitos.FirstOrDefault(a => a.NivelId == 10);
|
||||||
|
if (provincia != null && !string.IsNullOrEmpty(provincia.CodigoAmbitos.DistritoId))
|
||||||
|
{
|
||||||
|
// Usamos un try-catch porque no todas las categorías tienen agrupaciones a nivel provincial.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var agrupacionesApi = await _apiService.GetAgrupacionesAsync(authToken, provincia.CodigoAmbitos.DistritoId, categoria.CategoriaId);
|
||||||
|
if (agrupacionesApi != null && agrupacionesApi.Any())
|
||||||
|
{
|
||||||
|
foreach (var agrupacionDto in agrupacionesApi)
|
||||||
|
{
|
||||||
|
if (!agrupacionesEnDb.ContainsKey(agrupacionDto.IdAgrupacion))
|
||||||
|
{
|
||||||
|
var nuevaAgrupacion = new AgrupacionPolitica
|
||||||
|
{
|
||||||
|
Id = agrupacionDto.IdAgrupacion,
|
||||||
|
IdTelegrama = agrupacionDto.IdAgrupacionTelegrama,
|
||||||
|
Nombre = agrupacionDto.NombreAgrupacion
|
||||||
|
};
|
||||||
|
dbContext.AgrupacionesPoliticas.Add(nuevaAgrupacion);
|
||||||
|
agrupacionesEnDb.Add(nuevaAgrupacion.Id, nuevaAgrupacion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "No se pudieron obtener agrupaciones para la categoría '{catNombre}' ({catId}).", categoria.Nombre, categoria.CategoriaId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Después de procesar todos los ámbitos y agrupaciones de UNA categoría, guardamos los cambios.
|
||||||
|
// Esto divide la inserción masiva de ~50,000 registros en 3 transacciones más pequeñas,
|
||||||
|
// evitando timeouts y fallos en la base de datos.
|
||||||
|
if (dbContext.ChangeTracker.HasChanges())
|
||||||
|
{
|
||||||
|
int cambiosEnLote = await dbContext.SaveChangesAsync(stoppingToken);
|
||||||
|
totalCambiosGuardados += cambiosEnLote;
|
||||||
|
_logger.LogInformation("Guardados {count} registros de catálogo para la categoría '{catNombre}'.", cambiosEnLote, categoria.Nombre);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ya no hay un SaveChangesAsync() gigante aquí.
|
||||||
|
_logger.LogInformation("{count} nuevos registros de catálogo han sido guardados en total.", totalCambiosGuardados);
|
||||||
|
_logger.LogInformation("Sincronización de catálogos maestros finalizada.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Ocurrió un error CRÍTICO durante la sincronización de catálogos.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sondea la proyección de bancas. Este método ahora es más completo:
|
||||||
|
/// 1. Consulta el reparto de bancas a nivel PROVINCIAL para cada categoría.
|
||||||
|
/// 2. Consulta el reparto de bancas desglosado por SECCIÓN ELECTORAL para cada categoría.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Sondea la proyección de bancas a nivel Provincial y por Sección Electoral.
|
||||||
|
/// Esta versión recolecta todos los datos disponibles y los guarda en una única transacción.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SondearProyeccionBancasAsync(string authToken, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
||||||
|
|
||||||
|
var categoriasDeBancas = await dbContext.CategoriasElectorales
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.Nombre.Contains("SENADORES") || c.Nombre.Contains("DIPUTADOS"))
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
var provincia = await dbContext.AmbitosGeograficos
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken);
|
||||||
|
|
||||||
|
var seccionesElectorales = await dbContext.AmbitosGeograficos
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.NivelId == 20 && a.DistritoId != null && a.SeccionProvincialId != null)
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (!categoriasDeBancas.Any() || provincia == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No se encontraron categorías de bancas o el ámbito provincial en la BD. Omitiendo sondeo.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Iniciando sondeo de Bancas a nivel Provincial y para {count} Secciones Electorales...", seccionesElectorales.Count);
|
||||||
|
|
||||||
|
// Creamos una lista para recolectar todas las proyecciones que encontremos.
|
||||||
|
var nuevasProyecciones = new List<ProyeccionBanca>();
|
||||||
|
|
||||||
|
// 1. Bucle para el nivel Provincial
|
||||||
|
foreach (var categoria in categoriasDeBancas)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
var repartoBancas = await _apiService.GetBancasAsync(authToken, provincia.DistritoId!, null, categoria.Id);
|
||||||
|
|
||||||
|
// Si la lista de bancas no es nula (incluso si está vacía), la procesamos.
|
||||||
|
if (repartoBancas?.RepartoBancas != null)
|
||||||
|
{
|
||||||
|
foreach (var banca in repartoBancas.RepartoBancas)
|
||||||
|
{
|
||||||
|
nuevasProyecciones.Add(new ProyeccionBanca
|
||||||
|
{
|
||||||
|
AmbitoGeograficoId = provincia.Id,
|
||||||
|
AgrupacionPoliticaId = banca.IdAgrupacion,
|
||||||
|
NroBancas = banca.NroBancas
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Bucle para el nivel de Sección Electoral
|
||||||
|
foreach (var seccion in seccionesElectorales)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
foreach (var categoria in categoriasDeBancas)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
var repartoBancas = await _apiService.GetBancasAsync(authToken, seccion.DistritoId!, seccion.SeccionProvincialId!, categoria.Id);
|
||||||
|
|
||||||
|
if (repartoBancas?.RepartoBancas != null)
|
||||||
|
{
|
||||||
|
foreach (var banca in repartoBancas.RepartoBancas)
|
||||||
|
{
|
||||||
|
nuevasProyecciones.Add(new ProyeccionBanca
|
||||||
|
{
|
||||||
|
AmbitoGeograficoId = seccion.Id,
|
||||||
|
AgrupacionPoliticaId = banca.IdAgrupacion,
|
||||||
|
NroBancas = banca.NroBancas
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Guardado Final
|
||||||
|
// Ahora la condición es simple: si nuestra lista recolectora tiene CUALQUIER COSA, actualizamos la BD.
|
||||||
|
if (nuevasProyecciones.Any())
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Se recibieron {count} registros de proyección de bancas. Actualizando la tabla...", nuevasProyecciones.Count);
|
||||||
|
|
||||||
|
await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken);
|
||||||
|
|
||||||
|
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ProyeccionesBancas", stoppingToken);
|
||||||
|
await dbContext.ProyeccionesBancas.AddRangeAsync(nuevasProyecciones, stoppingToken);
|
||||||
|
await dbContext.SaveChangesAsync(stoppingToken);
|
||||||
|
await transaction.CommitAsync(stoppingToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("Sondeo de Bancas completado. La tabla de proyecciones ha sido actualizada.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Si después de todas las llamadas, la lista sigue vacía, no hacemos nada.
|
||||||
|
_logger.LogInformation("Sondeo de Bancas completado. No se encontraron datos de proyección, la tabla no fue modificada.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Bancas.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Busca y descarga nuevos telegramas de forma masiva y concurrente.
|
||||||
|
/// Este método crea una lista de todas las combinaciones de Partido/Categoría,
|
||||||
|
/// las consulta a la API con un grado de paralelismo controlado, y cada tarea concurrente
|
||||||
|
/// maneja su propia lógica de descarga y guardado en la base de datos.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authToken">El token de autenticación válido para la sesión.</param>
|
||||||
|
/// <param name="stoppingToken">El token de cancelación para detener la operación.</param>
|
||||||
|
private async Task SondearNuevosTelegramasAsync(string authToken, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("--- Iniciando sondeo de Nuevos Telegramas (modo de bajo perfil) ---");
|
||||||
|
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
||||||
|
|
||||||
|
var partidos = await dbContext.AmbitosGeograficos
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null)
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
var categorias = await dbContext.CategoriasElectorales
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (!partidos.Any() || !categorias.Any()) return;
|
||||||
|
|
||||||
|
// --- LÓGICA DE GOTEO LENTO ---
|
||||||
|
// Procesamos una combinación (partido/categoría) a la vez.
|
||||||
|
foreach (var partido in partidos)
|
||||||
|
{
|
||||||
|
foreach (var categoria in categorias)
|
||||||
|
{
|
||||||
|
// Si la aplicación se apaga, salimos inmediatamente.
|
||||||
|
if (stoppingToken.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
// Obtenemos la lista de IDs.
|
||||||
|
var listaTelegramasApi = await _apiService.GetTelegramasTotalizadosAsync(authToken, partido.DistritoId!, partido.SeccionId!, categoria.Id);
|
||||||
|
|
||||||
|
if (listaTelegramasApi is { Count: > 0 })
|
||||||
|
{
|
||||||
|
// Usamos un DbContext propio para este bloque para asegurar que los cambios se guarden.
|
||||||
|
using var innerScope = _serviceProvider.CreateScope();
|
||||||
|
var innerDbContext = innerScope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
||||||
|
|
||||||
|
var idsYaEnDb = await innerDbContext.Telegramas
|
||||||
|
.Where(t => listaTelegramasApi.Contains(t.Id))
|
||||||
|
.Select(t => t.Id)
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
var nuevosTelegramasIds = listaTelegramasApi.Except(idsYaEnDb).ToList();
|
||||||
|
|
||||||
|
if (nuevosTelegramasIds.Any())
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Se encontraron {count} telegramas nuevos en '{partido}' para '{cat}'. Descargando...", nuevosTelegramasIds.Count, partido.Nombre, categoria.Nombre);
|
||||||
|
|
||||||
|
// Descargamos los archivos de uno en uno, con una pausa entre cada uno.
|
||||||
|
foreach (var mesaId in nuevosTelegramasIds)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
var telegramaFile = await _apiService.GetTelegramaFileAsync(authToken, mesaId);
|
||||||
|
if (telegramaFile != null)
|
||||||
|
{
|
||||||
|
var nuevoTelegrama = new Telegrama
|
||||||
|
{
|
||||||
|
Id = telegramaFile.NombreArchivo,
|
||||||
|
AmbitoGeograficoId = partido.Id,
|
||||||
|
ContenidoBase64 = telegramaFile.Imagen,
|
||||||
|
FechaEscaneo = DateTime.Parse(telegramaFile.FechaEscaneo).ToUniversalTime(),
|
||||||
|
FechaTotalizacion = DateTime.Parse(telegramaFile.FechaTotalizacion).ToUniversalTime()
|
||||||
|
};
|
||||||
|
await innerDbContext.Telegramas.AddAsync(nuevoTelegrama, stoppingToken);
|
||||||
|
}
|
||||||
|
// PAUSA DELIBERADA: Esperamos un poco para no parecer un bot.
|
||||||
|
await Task.Delay(250, stoppingToken); // 250ms de espera = 4 peticiones/segundo máximo.
|
||||||
|
}
|
||||||
|
await innerDbContext.SaveChangesAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAUSA DELIBERADA: Esperamos un poco entre cada consulta de lista de telegramas.
|
||||||
|
await Task.Delay(100, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_logger.LogInformation("Sondeo de Telegramas completado.");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Sondeo de telegramas cancelado.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Telegramas.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pega aquí los métodos:
|
||||||
|
// - SincronizarCatalogosMaestrosAsync
|
||||||
|
// - SondearProyeccionBancasAsync
|
||||||
|
// - SondearNuevosTelegramasAsync (la versión con goteo lento)
|
||||||
|
}
|
||||||
@@ -104,7 +104,13 @@ builder.Services.AddHttpClient("ElectoralApiClient", client =>
|
|||||||
*/
|
*/
|
||||||
builder.Services.AddScoped<IElectoralApiService, ElectoralApiService>();
|
builder.Services.AddScoped<IElectoralApiService, ElectoralApiService>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<Worker>();
|
// Registramos el servicio de token como un Singleton para que sea compartido.
|
||||||
|
builder.Services.AddSingleton<SharedTokenService>();
|
||||||
|
|
||||||
|
// Registramos ambos workers. El framework se encargará de iniciarlos y detenerlos.
|
||||||
|
builder.Services.AddHostedService<CriticalDataWorker>();
|
||||||
|
builder.Services.AddHostedService<LowPriorityDataWorker>();
|
||||||
|
//builder.Services.AddHostedService<Worker>();
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -1,848 +0,0 @@
|
|||||||
using Elecciones.Database;
|
|
||||||
using Elecciones.Database.Entities;
|
|
||||||
using Elecciones.Infrastructure.Services;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace Elecciones.Worker;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Servicio de fondo (BackgroundService) responsable de sincronizar y sondear
|
|
||||||
/// periódicamente los datos de la API electoral.
|
|
||||||
/// </summary>
|
|
||||||
public class Worker : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly ILogger<Worker> _logger;
|
|
||||||
private readonly IElectoralApiService _apiService;
|
|
||||||
private readonly IServiceProvider _serviceProvider;
|
|
||||||
// --- VARIABLES DE ESTADO PARA EL TOKEN ---
|
|
||||||
private string? _authToken;
|
|
||||||
// Usamos DateTimeOffset para manejar correctamente las zonas horarias.
|
|
||||||
private DateTimeOffset _tokenExpiration = DateTimeOffset.MinValue;
|
|
||||||
|
|
||||||
public Worker(ILogger<Worker> logger, IElectoralApiService apiService, IServiceProvider serviceProvider)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_apiService = apiService;
|
|
||||||
_serviceProvider = serviceProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Obtiene un token de autenticación válido, solicitando uno nuevo solo si el actual
|
|
||||||
/// no existe o ha expirado.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<string?> GetValidAuthTokenAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
// Comprobamos si el token es nulo o si la fecha de expiración ya pasó.
|
|
||||||
// Añadimos un buffer de seguridad de 1 minuto para renovarlo un poco antes.
|
|
||||||
if (string.IsNullOrEmpty(_authToken) || DateTimeOffset.UtcNow >= _tokenExpiration.AddMinutes(-1))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Token no válido o a punto de expirar. Solicitando uno nuevo...");
|
|
||||||
var tokenResponse = await _apiService.GetAuthTokenAsync(); // Asumimos que el ApiService devuelve el objeto completo
|
|
||||||
|
|
||||||
if (tokenResponse?.Data?.AccessToken != null)
|
|
||||||
{
|
|
||||||
_authToken = tokenResponse.Data.AccessToken;
|
|
||||||
// Calculamos la nueva fecha de expiración. La API nos da la duración en segundos.
|
|
||||||
_tokenExpiration = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.Data.ExpiresIn);
|
|
||||||
_logger.LogInformation("Nuevo token obtenido. Válido hasta: {expiration}", _tokenExpiration);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogError("CRÍTICO: No se pudo obtener un nuevo token de autenticación.");
|
|
||||||
_authToken = null; // Nos aseguramos de que el token viejo se invalide
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return _authToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Método principal del worker que se ejecuta en segundo plano.
|
|
||||||
/// </summary>
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Elecciones Worker iniciado a las: {time}", DateTimeOffset.Now);
|
|
||||||
|
|
||||||
await SincronizarCatalogosMaestrosAsync(stoppingToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("-------------------------------------------------");
|
|
||||||
_logger.LogInformation("Iniciando sondeo periódico de resultados...");
|
|
||||||
_logger.LogInformation("-------------------------------------------------");
|
|
||||||
|
|
||||||
int cicloContador = 0;
|
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
var cicloInicio = DateTime.UtcNow;
|
|
||||||
cicloContador++;
|
|
||||||
|
|
||||||
var authToken = await GetValidAuthTokenAsync(stoppingToken);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(authToken))
|
|
||||||
{
|
|
||||||
_logger.LogError("No se pudo obtener un token válido. Reintentando en 1 minuto...");
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CICLO CALIENTE: TAREAS DE ALTA PRIORIDAD (SIEMPRE SE EJECUTAN) ---
|
|
||||||
_logger.LogInformation("--- Iniciando Ciclo Caliente #{ciclo} ---", cicloContador);
|
|
||||||
|
|
||||||
await SondearResultadosMunicipalesAsync(authToken, stoppingToken);
|
|
||||||
await SondearResumenProvincialAsync(authToken, stoppingToken);
|
|
||||||
await SondearEstadoRecuentoGeneralAsync(authToken, stoppingToken);
|
|
||||||
|
|
||||||
// --- CICLO FRÍO: TAREAS DE BAJA PRIORIDAD (SE EJECUTAN CADA 5 CICLOS) ---
|
|
||||||
// El operador '%' (módulo) nos dice si el contador es divisible por 5.
|
|
||||||
if (cicloContador % 5 == 1) // Se ejecuta en el ciclo 1, 6, 11, etc.
|
|
||||||
{
|
|
||||||
_logger.LogInformation("--- Iniciando Ciclo Frío (Bancas y Telegramas) ---");
|
|
||||||
await SondearProyeccionBancasAsync(authToken, stoppingToken);
|
|
||||||
await SondearNuevosTelegramasAsync(authToken, stoppingToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
var cicloFin = DateTime.UtcNow;
|
|
||||||
var duracionCiclo = cicloFin - cicloInicio;
|
|
||||||
_logger.LogInformation("Ciclo #{ciclo} completado en {duration} segundos.", cicloContador, duracionCiclo.TotalSeconds);
|
|
||||||
|
|
||||||
// --- ESPERA INTELIGENTE ---
|
|
||||||
// Esperamos lo que quede para completar 1 minuto desde el inicio del ciclo.
|
|
||||||
// Si el ciclo tardó 20 segundos, esperamos 40. Si tardó más de 1 minuto, la espera es mínima.
|
|
||||||
var tiempoDeEspera = TimeSpan.FromMinutes(1) - duracionCiclo;
|
|
||||||
if (tiempoDeEspera < TimeSpan.Zero)
|
|
||||||
{
|
|
||||||
tiempoDeEspera = TimeSpan.FromSeconds(5); // Una espera mínima si el ciclo se excedió
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Esperando {wait_seconds} segundos para el siguiente ciclo...", tiempoDeEspera.TotalSeconds);
|
|
||||||
await Task.Delay(tiempoDeEspera, stoppingToken);
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Elecciones Worker se está deteniendo.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Descarga y sincroniza los catálogos base (Categorías, Ámbitos, Agrupaciones)
|
|
||||||
/// desde la API a la base de datos local. Se ejecuta una sola vez al iniciar el worker.
|
|
||||||
/// Utiliza una estrategia de guardado en lotes para manejar grandes volúmenes de datos
|
|
||||||
/// sin sobrecargar la base de datos.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="stoppingToken">El token de cancelación para detener la operación.</param>
|
|
||||||
private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Iniciando sincronización de catálogos maestros...");
|
|
||||||
|
|
||||||
var authToken = await GetValidAuthTokenAsync(stoppingToken);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(authToken) || stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
_logger.LogError("No se pudo obtener token para la sincronización de catálogos. La operación se cancela.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creamos un scope de servicios para obtener una instancia fresca de DbContext.
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
|
||||||
|
|
||||||
// PASO 2: Sincronizar las categorías electorales.
|
|
||||||
// Es un catálogo pequeño y es la base para las siguientes consultas.
|
|
||||||
var categoriasApi = await _apiService.GetCategoriasAsync(authToken);
|
|
||||||
if (categoriasApi is null || !categoriasApi.Any())
|
|
||||||
{
|
|
||||||
_logger.LogWarning("La API no devolvió datos para el catálogo de Categorías. La sincronización no puede continuar.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var distinctCategorias = categoriasApi.GroupBy(c => c.CategoriaId).Select(g => g.First()).OrderBy(c => c.Orden).ToList();
|
|
||||||
_logger.LogInformation("Se procesarán {count} categorías electorales.", distinctCategorias.Count);
|
|
||||||
|
|
||||||
var categoriasEnDb = await dbContext.CategoriasElectorales.ToDictionaryAsync(c => c.Id, c => c, stoppingToken);
|
|
||||||
foreach (var categoriaDto in distinctCategorias)
|
|
||||||
{
|
|
||||||
if (!categoriasEnDb.ContainsKey(categoriaDto.CategoriaId))
|
|
||||||
{
|
|
||||||
dbContext.CategoriasElectorales.Add(new CategoriaElectoral { Id = categoriaDto.CategoriaId, Nombre = categoriaDto.Nombre, Orden = categoriaDto.Orden });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Guardamos las categorías primero para asegurar su existencia.
|
|
||||||
await dbContext.SaveChangesAsync(stoppingToken);
|
|
||||||
|
|
||||||
// PASO 3: Cargar los catálogos existentes en memoria para una comparación eficiente.
|
|
||||||
// Esto evita hacer miles de consultas a la BD dentro de un bucle.
|
|
||||||
|
|
||||||
// Para los ámbitos, creamos una clave única robusta que funciona incluso con campos nulos.
|
|
||||||
var ambitosEnDb = new Dictionary<string, AmbitoGeografico>();
|
|
||||||
var todosLosAmbitos = await dbContext.AmbitosGeograficos.ToListAsync(stoppingToken);
|
|
||||||
foreach (var ambito in todosLosAmbitos)
|
|
||||||
{
|
|
||||||
string clave = $"{ambito.NivelId}|{ambito.DistritoId}|{ambito.SeccionProvincialId}|{ambito.SeccionId}|{ambito.MunicipioId}|{ambito.CircuitoId}|{ambito.EstablecimientoId}|{ambito.MesaId}";
|
|
||||||
if (!ambitosEnDb.ContainsKey(clave))
|
|
||||||
{
|
|
||||||
ambitosEnDb.Add(clave, ambito);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var agrupacionesEnDb = await dbContext.AgrupacionesPoliticas.ToDictionaryAsync(a => a.Id, a => a, stoppingToken);
|
|
||||||
|
|
||||||
// Variable para llevar la cuenta del total de registros insertados.
|
|
||||||
int totalCambiosGuardados = 0;
|
|
||||||
|
|
||||||
// PASO 4: Iterar sobre cada categoría para sincronizar sus ámbitos y agrupaciones.
|
|
||||||
foreach (var categoria in distinctCategorias)
|
|
||||||
{
|
|
||||||
if (stoppingToken.IsCancellationRequested) break;
|
|
||||||
_logger.LogInformation("--- Sincronizando datos para la categoría: {Nombre} (ID: {Id}) ---", categoria.Nombre, categoria.CategoriaId);
|
|
||||||
|
|
||||||
var catalogoDto = await _apiService.GetCatalogoAmbitosAsync(authToken, categoria.CategoriaId);
|
|
||||||
if (catalogoDto != null)
|
|
||||||
{
|
|
||||||
// 4.1 - Procesar y añadir ÁMBITOS nuevos al DbContext
|
|
||||||
foreach (var ambitoDto in catalogoDto.Ambitos)
|
|
||||||
{
|
|
||||||
string claveUnica = $"{ambitoDto.NivelId}|{ambitoDto.CodigoAmbitos.DistritoId}|{ambitoDto.CodigoAmbitos.SeccionProvincialId}|{ambitoDto.CodigoAmbitos.SeccionId}|{ambitoDto.CodigoAmbitos.MunicipioId}|{ambitoDto.CodigoAmbitos.CircuitoId}|{ambitoDto.CodigoAmbitos.EstablecimientoId}|{ambitoDto.CodigoAmbitos.MesaId}";
|
|
||||||
|
|
||||||
if (!ambitosEnDb.ContainsKey(claveUnica))
|
|
||||||
{
|
|
||||||
var nuevoAmbito = new AmbitoGeografico
|
|
||||||
{
|
|
||||||
Nombre = ambitoDto.Nombre,
|
|
||||||
NivelId = ambitoDto.NivelId,
|
|
||||||
DistritoId = ambitoDto.CodigoAmbitos.DistritoId,
|
|
||||||
SeccionProvincialId = ambitoDto.CodigoAmbitos.SeccionProvincialId,
|
|
||||||
SeccionId = ambitoDto.CodigoAmbitos.SeccionId,
|
|
||||||
MunicipioId = ambitoDto.CodigoAmbitos.MunicipioId,
|
|
||||||
CircuitoId = ambitoDto.CodigoAmbitos.CircuitoId,
|
|
||||||
EstablecimientoId = ambitoDto.CodigoAmbitos.EstablecimientoId,
|
|
||||||
MesaId = ambitoDto.CodigoAmbitos.MesaId,
|
|
||||||
};
|
|
||||||
dbContext.AmbitosGeograficos.Add(nuevoAmbito);
|
|
||||||
ambitosEnDb.Add(claveUnica, nuevoAmbito); // Añadir también al diccionario en memoria
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4.2 - Procesar y añadir AGRUPACIONES nuevas al DbContext
|
|
||||||
var provincia = catalogoDto.Ambitos.FirstOrDefault(a => a.NivelId == 10);
|
|
||||||
if (provincia != null && !string.IsNullOrEmpty(provincia.CodigoAmbitos.DistritoId))
|
|
||||||
{
|
|
||||||
// Usamos un try-catch porque no todas las categorías tienen agrupaciones a nivel provincial.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var agrupacionesApi = await _apiService.GetAgrupacionesAsync(authToken, provincia.CodigoAmbitos.DistritoId, categoria.CategoriaId);
|
|
||||||
if (agrupacionesApi != null && agrupacionesApi.Any())
|
|
||||||
{
|
|
||||||
foreach (var agrupacionDto in agrupacionesApi)
|
|
||||||
{
|
|
||||||
if (!agrupacionesEnDb.ContainsKey(agrupacionDto.IdAgrupacion))
|
|
||||||
{
|
|
||||||
var nuevaAgrupacion = new AgrupacionPolitica
|
|
||||||
{
|
|
||||||
Id = agrupacionDto.IdAgrupacion,
|
|
||||||
IdTelegrama = agrupacionDto.IdAgrupacionTelegrama,
|
|
||||||
Nombre = agrupacionDto.NombreAgrupacion
|
|
||||||
};
|
|
||||||
dbContext.AgrupacionesPoliticas.Add(nuevaAgrupacion);
|
|
||||||
agrupacionesEnDb.Add(nuevaAgrupacion.Id, nuevaAgrupacion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "No se pudieron obtener agrupaciones para la categoría '{catNombre}' ({catId}).", categoria.Nombre, categoria.CategoriaId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Después de procesar todos los ámbitos y agrupaciones de UNA categoría, guardamos los cambios.
|
|
||||||
// Esto divide la inserción masiva de ~50,000 registros en 3 transacciones más pequeñas,
|
|
||||||
// evitando timeouts y fallos en la base de datos.
|
|
||||||
if (dbContext.ChangeTracker.HasChanges())
|
|
||||||
{
|
|
||||||
int cambiosEnLote = await dbContext.SaveChangesAsync(stoppingToken);
|
|
||||||
totalCambiosGuardados += cambiosEnLote;
|
|
||||||
_logger.LogInformation("Guardados {count} registros de catálogo para la categoría '{catNombre}'.", cambiosEnLote, categoria.Nombre);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ya no hay un SaveChangesAsync() gigante aquí.
|
|
||||||
_logger.LogInformation("{count} nuevos registros de catálogo han sido guardados en total.", totalCambiosGuardados);
|
|
||||||
_logger.LogInformation("Sincronización de catálogos maestros finalizada.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Ocurrió un error CRÍTICO durante la sincronización de catálogos.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sondea los resultados electorales para todos los municipios/partidos de forma optimizada.
|
|
||||||
/// Utiliza paralelismo controlado para ejecutar múltiples peticiones a la API simultáneamente
|
|
||||||
/// sin sobrecargar la red, y luego guarda todos los resultados en la base de datos de forma masiva.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="authToken">El token de autenticación válido para la sesión.</param>
|
|
||||||
/// <param name="stoppingToken">El token de cancelación para detener la operación.</param>
|
|
||||||
private async Task SondearResultadosMunicipalesAsync(string authToken, CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// PASO 1: Preparar el DbContext y los datos necesarios.
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
|
||||||
|
|
||||||
// Obtenemos de nuestra BD local la lista de todos los partidos (NivelId=30) que necesitamos consultar.
|
|
||||||
var municipiosASondear = await dbContext.AmbitosGeograficos
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null)
|
|
||||||
.Select(a => new { a.Id, a.Nombre, a.MunicipioId, a.SeccionId, a.DistritoId })
|
|
||||||
.ToListAsync(stoppingToken);
|
|
||||||
|
|
||||||
if (!municipiosASondear.Any())
|
|
||||||
{
|
|
||||||
_logger.LogWarning("No se encontraron Partidos (NivelId 30) en la BD para sondear resultados.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Obtenemos la categoría "CONCEJALES", ya que los resultados municipales aplican a esta.
|
|
||||||
var categoriaConcejales = await dbContext.CategoriasElectorales
|
|
||||||
.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(c => c.Nombre.Contains("CONCEJALES"), stoppingToken);
|
|
||||||
|
|
||||||
if (categoriaConcejales == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("No se encontró la categoría 'CONCEJALES'. Omitiendo sondeo de resultados municipales.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PASO 2: Ejecutar las consultas a la API con paralelismo controlado.
|
|
||||||
|
|
||||||
// Definimos cuántas peticiones queremos que se ejecuten simultáneamente.
|
|
||||||
// Un valor entre 8 y 16 es generalmente seguro y ofrece una gran mejora de velocidad.
|
|
||||||
const int GRADO_DE_PARALELISMO = 3;
|
|
||||||
// Creamos un semáforo que actuará como un "control de acceso" con 10 pases libres.
|
|
||||||
var semaforo = new SemaphoreSlim(GRADO_DE_PARALELISMO);
|
|
||||||
|
|
||||||
// Usamos un ConcurrentDictionary para almacenar los resultados. A diferencia de un Dictionary normal,
|
|
||||||
// este permite que múltiples tareas escriban en él al mismo tiempo sin conflictos.
|
|
||||||
var resultadosPorId = new ConcurrentDictionary<int, Elecciones.Core.DTOs.ResultadosDto>();
|
|
||||||
|
|
||||||
_logger.LogInformation("Iniciando sondeo de resultados para {count} municipios con un paralelismo de {degree}...", municipiosASondear.Count, GRADO_DE_PARALELISMO);
|
|
||||||
|
|
||||||
// Creamos una lista de tareas (Tasks), una por cada municipio a consultar.
|
|
||||||
// El método .Select() no ejecuta las tareas todavía, solo las prepara.
|
|
||||||
var tareas = municipiosASondear.Select(async municipio =>
|
|
||||||
{
|
|
||||||
// Cada tarea debe "pedir permiso" al semáforo antes de ejecutarse.
|
|
||||||
// Si ya hay 10 tareas en ejecución, esta línea esperará hasta que una termine.
|
|
||||||
await semaforo.WaitAsync(stoppingToken);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Una vez que obtiene el permiso, ejecuta la petición a la API.
|
|
||||||
var resultados = await _apiService.GetResultadosAsync(
|
|
||||||
authToken, municipio.DistritoId!, municipio.SeccionId!, null, categoriaConcejales.Id
|
|
||||||
);
|
|
||||||
|
|
||||||
// Si la API devuelve datos válidos...
|
|
||||||
if (resultados != null)
|
|
||||||
{
|
|
||||||
// ...los guardamos en el diccionario concurrente.
|
|
||||||
resultadosPorId[municipio.Id] = resultados;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// ¡CRUCIAL! Liberamos el pase del semáforo, permitiendo que la siguiente
|
|
||||||
// tarea en espera pueda comenzar su ejecución.
|
|
||||||
semaforo.Release();
|
|
||||||
// Añadir un pequeño retraso aleatorio para no parecer un robot
|
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(new Random().Next(50, 251)), stoppingToken);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ahora sí, ejecutamos todas las tareas preparadas en paralelo y esperamos a que todas terminen.
|
|
||||||
await Task.WhenAll(tareas);
|
|
||||||
|
|
||||||
// PASO 3: Guardar los resultados en la base de datos.
|
|
||||||
// Solo procedemos si recolectamos al menos un resultado válido.
|
|
||||||
if (resultadosPorId.Any())
|
|
||||||
{
|
|
||||||
// Llamamos a nuestro método de guardado masivo y optimizado, pasándole todos los resultados
|
|
||||||
// recolectados para que los inserte en una única y eficiente transacción.
|
|
||||||
await GuardarResultadosDeMunicipiosAsync(dbContext, resultadosPorId.ToDictionary(kv => kv.Key, kv => kv.Value), stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Capturamos cualquier error inesperado en el proceso para que el worker no se detenga.
|
|
||||||
_logger.LogError(ex, "Ocurrió un error inesperado durante el sondeo de resultados municipales.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Realiza una operación "Upsert" (Update o Insert) de forma masiva y optimizada.
|
|
||||||
/// Este método es llamado por SondearResultadosMunicipalesAsync.
|
|
||||||
/// </summary>
|
|
||||||
private async Task GuardarResultadosDeMunicipiosAsync(
|
|
||||||
EleccionesDbContext dbContext,
|
|
||||||
Dictionary<int, Elecciones.Core.DTOs.ResultadosDto> todosLosResultados,
|
|
||||||
CancellationToken stoppingToken) // <-- PARÁMETRO AÑADIDO
|
|
||||||
{
|
|
||||||
// Obtenemos los IDs de todos los ámbitos que vamos a actualizar.
|
|
||||||
var ambitoIds = todosLosResultados.Keys;
|
|
||||||
|
|
||||||
// --- OPTIMIZACIÓN 1: Cargar todos los datos existentes en memoria UNA SOLA VEZ ---
|
|
||||||
var estadosRecuentoExistentes = await dbContext.EstadosRecuentos
|
|
||||||
.Where(e => ambitoIds.Contains(e.AmbitoGeograficoId))
|
|
||||||
.ToDictionaryAsync(e => e.AmbitoGeograficoId, stoppingToken);
|
|
||||||
|
|
||||||
var resultadosVotosExistentes = await dbContext.ResultadosVotos
|
|
||||||
.Where(rv => ambitoIds.Contains(rv.AmbitoGeograficoId))
|
|
||||||
.GroupBy(rv => rv.AmbitoGeograficoId)
|
|
||||||
.ToDictionaryAsync(g => g.Key, g => g.ToDictionary(item => item.AgrupacionPoliticaId), stoppingToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("Procesando en memoria los resultados de {count} municipios.", todosLosResultados.Count);
|
|
||||||
|
|
||||||
// --- OPTIMIZACIÓN 2: Procesar todo en memoria ---
|
|
||||||
foreach (var kvp in todosLosResultados)
|
|
||||||
{
|
|
||||||
var ambitoId = kvp.Key;
|
|
||||||
var resultadosDto = kvp.Value;
|
|
||||||
|
|
||||||
// Lógica Upsert para EstadoRecuento
|
|
||||||
if (!estadosRecuentoExistentes.TryGetValue(ambitoId, out var estadoRecuento))
|
|
||||||
{
|
|
||||||
estadoRecuento = new EstadoRecuento { AmbitoGeograficoId = ambitoId };
|
|
||||||
dbContext.EstadosRecuentos.Add(estadoRecuento);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mapeo completo de propiedades para EstadoRecuento
|
|
||||||
estadoRecuento.FechaTotalizacion = DateTime.Parse(resultadosDto.FechaTotalizacion).ToUniversalTime();
|
|
||||||
estadoRecuento.MesasEsperadas = resultadosDto.EstadoRecuento.MesasEsperadas;
|
|
||||||
estadoRecuento.MesasTotalizadas = resultadosDto.EstadoRecuento.MesasTotalizadas;
|
|
||||||
estadoRecuento.MesasTotalizadasPorcentaje = resultadosDto.EstadoRecuento.MesasTotalizadasPorcentaje;
|
|
||||||
estadoRecuento.CantidadElectores = resultadosDto.EstadoRecuento.CantidadElectores;
|
|
||||||
estadoRecuento.CantidadVotantes = resultadosDto.EstadoRecuento.CantidadVotantes;
|
|
||||||
estadoRecuento.ParticipacionPorcentaje = resultadosDto.EstadoRecuento.ParticipacionPorcentaje;
|
|
||||||
|
|
||||||
if (resultadosDto.ValoresTotalizadosOtros != null)
|
|
||||||
{
|
|
||||||
estadoRecuento.VotosEnBlanco = resultadosDto.ValoresTotalizadosOtros.VotosEnBlanco;
|
|
||||||
estadoRecuento.VotosEnBlancoPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosEnBlancoPorcentaje;
|
|
||||||
estadoRecuento.VotosNulos = resultadosDto.ValoresTotalizadosOtros.VotosNulos;
|
|
||||||
estadoRecuento.VotosNulosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosNulosPorcentaje;
|
|
||||||
estadoRecuento.VotosRecurridos = resultadosDto.ValoresTotalizadosOtros.VotosRecurridos;
|
|
||||||
estadoRecuento.VotosRecurridosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosRecurridosPorcentaje;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lógica Upsert para ResultadosVotos
|
|
||||||
var votosDeAmbitoExistentes = resultadosVotosExistentes.GetValueOrDefault(ambitoId);
|
|
||||||
foreach (var votoPositivoDto in resultadosDto.ValoresTotalizadosPositivos)
|
|
||||||
{
|
|
||||||
ResultadoVoto? resultadoVoto = null;
|
|
||||||
if (votosDeAmbitoExistentes != null)
|
|
||||||
{
|
|
||||||
votosDeAmbitoExistentes.TryGetValue(votoPositivoDto.IdAgrupacion, out resultadoVoto);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resultadoVoto == null)
|
|
||||||
{
|
|
||||||
resultadoVoto = new ResultadoVoto
|
|
||||||
{
|
|
||||||
AmbitoGeograficoId = ambitoId,
|
|
||||||
AgrupacionPoliticaId = votoPositivoDto.IdAgrupacion
|
|
||||||
};
|
|
||||||
dbContext.ResultadosVotos.Add(resultadoVoto);
|
|
||||||
}
|
|
||||||
resultadoVoto.CantidadVotos = votoPositivoDto.Votos;
|
|
||||||
resultadoVoto.PorcentajeVotos = votoPositivoDto.VotosPorcentaje;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- OPTIMIZACIÓN 3: Guardar todos los cambios en UNA SOLA TRANSACCIÓN ---
|
|
||||||
_logger.LogInformation("Guardando todos los cambios de resultados municipales en la base de datos...");
|
|
||||||
// Ahora 'stoppingToken' es reconocido aquí
|
|
||||||
await dbContext.SaveChangesAsync(stoppingToken);
|
|
||||||
_logger.LogInformation("Guardado completado.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sondea la proyección de bancas. Este método ahora es más completo:
|
|
||||||
/// 1. Consulta el reparto de bancas a nivel PROVINCIAL para cada categoría.
|
|
||||||
/// 2. Consulta el reparto de bancas desglosado por SECCIÓN ELECTORAL para cada categoría.
|
|
||||||
/// </summary>
|
|
||||||
/// <summary>
|
|
||||||
/// Sondea la proyección de bancas a nivel Provincial y por Sección Electoral.
|
|
||||||
/// Esta versión recolecta todos los datos disponibles y los guarda en una única transacción.
|
|
||||||
/// </summary>
|
|
||||||
private async Task SondearProyeccionBancasAsync(string authToken, CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
|
||||||
|
|
||||||
var categoriasDeBancas = await dbContext.CategoriasElectorales
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(c => c.Nombre.Contains("SENADORES") || c.Nombre.Contains("DIPUTADOS"))
|
|
||||||
.ToListAsync(stoppingToken);
|
|
||||||
|
|
||||||
var provincia = await dbContext.AmbitosGeograficos
|
|
||||||
.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken);
|
|
||||||
|
|
||||||
var seccionesElectorales = await dbContext.AmbitosGeograficos
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(a => a.NivelId == 20 && a.DistritoId != null && a.SeccionProvincialId != null)
|
|
||||||
.ToListAsync(stoppingToken);
|
|
||||||
|
|
||||||
if (!categoriasDeBancas.Any() || provincia == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("No se encontraron categorías de bancas o el ámbito provincial en la BD. Omitiendo sondeo.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Iniciando sondeo de Bancas a nivel Provincial y para {count} Secciones Electorales...", seccionesElectorales.Count);
|
|
||||||
|
|
||||||
// Creamos una lista para recolectar todas las proyecciones que encontremos.
|
|
||||||
var nuevasProyecciones = new List<ProyeccionBanca>();
|
|
||||||
|
|
||||||
// 1. Bucle para el nivel Provincial
|
|
||||||
foreach (var categoria in categoriasDeBancas)
|
|
||||||
{
|
|
||||||
if (stoppingToken.IsCancellationRequested) break;
|
|
||||||
var repartoBancas = await _apiService.GetBancasAsync(authToken, provincia.DistritoId!, null, categoria.Id);
|
|
||||||
|
|
||||||
// Si la lista de bancas no es nula (incluso si está vacía), la procesamos.
|
|
||||||
if (repartoBancas?.RepartoBancas != null)
|
|
||||||
{
|
|
||||||
foreach (var banca in repartoBancas.RepartoBancas)
|
|
||||||
{
|
|
||||||
nuevasProyecciones.Add(new ProyeccionBanca
|
|
||||||
{
|
|
||||||
AmbitoGeograficoId = provincia.Id,
|
|
||||||
AgrupacionPoliticaId = banca.IdAgrupacion,
|
|
||||||
NroBancas = banca.NroBancas
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Bucle para el nivel de Sección Electoral
|
|
||||||
foreach (var seccion in seccionesElectorales)
|
|
||||||
{
|
|
||||||
if (stoppingToken.IsCancellationRequested) break;
|
|
||||||
foreach (var categoria in categoriasDeBancas)
|
|
||||||
{
|
|
||||||
if (stoppingToken.IsCancellationRequested) break;
|
|
||||||
var repartoBancas = await _apiService.GetBancasAsync(authToken, seccion.DistritoId!, seccion.SeccionProvincialId!, categoria.Id);
|
|
||||||
|
|
||||||
if (repartoBancas?.RepartoBancas != null)
|
|
||||||
{
|
|
||||||
foreach (var banca in repartoBancas.RepartoBancas)
|
|
||||||
{
|
|
||||||
nuevasProyecciones.Add(new ProyeccionBanca
|
|
||||||
{
|
|
||||||
AmbitoGeograficoId = seccion.Id,
|
|
||||||
AgrupacionPoliticaId = banca.IdAgrupacion,
|
|
||||||
NroBancas = banca.NroBancas
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Guardado Final
|
|
||||||
// Ahora la condición es simple: si nuestra lista recolectora tiene CUALQUIER COSA, actualizamos la BD.
|
|
||||||
if (nuevasProyecciones.Any())
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Se recibieron {count} registros de proyección de bancas. Actualizando la tabla...", nuevasProyecciones.Count);
|
|
||||||
|
|
||||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken);
|
|
||||||
|
|
||||||
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ProyeccionesBancas", stoppingToken);
|
|
||||||
await dbContext.ProyeccionesBancas.AddRangeAsync(nuevasProyecciones, stoppingToken);
|
|
||||||
await dbContext.SaveChangesAsync(stoppingToken);
|
|
||||||
await transaction.CommitAsync(stoppingToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("Sondeo de Bancas completado. La tabla de proyecciones ha sido actualizada.");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Si después de todas las llamadas, la lista sigue vacía, no hacemos nada.
|
|
||||||
_logger.LogInformation("Sondeo de Bancas completado. No se encontraron datos de proyección, la tabla no fue modificada.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Bancas.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Busca y descarga nuevos telegramas de forma masiva y concurrente.
|
|
||||||
/// Este método crea una lista de todas las combinaciones de Partido/Categoría,
|
|
||||||
/// las consulta a la API con un grado de paralelismo controlado, y cada tarea concurrente
|
|
||||||
/// maneja su propia lógica de descarga y guardado en la base de datos.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="authToken">El token de autenticación válido para la sesión.</param>
|
|
||||||
/// <param name="stoppingToken">El token de cancelación para detener la operación.</param>
|
|
||||||
private async Task SondearNuevosTelegramasAsync(string authToken, CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogInformation("--- Iniciando sondeo de Nuevos Telegramas (modo de bajo perfil) ---");
|
|
||||||
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
|
||||||
|
|
||||||
var partidos = await dbContext.AmbitosGeograficos
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null)
|
|
||||||
.ToListAsync(stoppingToken);
|
|
||||||
|
|
||||||
var categorias = await dbContext.CategoriasElectorales
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(stoppingToken);
|
|
||||||
|
|
||||||
if (!partidos.Any() || !categorias.Any()) return;
|
|
||||||
|
|
||||||
// --- LÓGICA DE GOTEO LENTO ---
|
|
||||||
// Procesamos una combinación (partido/categoría) a la vez.
|
|
||||||
foreach (var partido in partidos)
|
|
||||||
{
|
|
||||||
foreach (var categoria in categorias)
|
|
||||||
{
|
|
||||||
// Si la aplicación se apaga, salimos inmediatamente.
|
|
||||||
if (stoppingToken.IsCancellationRequested) return;
|
|
||||||
|
|
||||||
// Obtenemos la lista de IDs.
|
|
||||||
var listaTelegramasApi = await _apiService.GetTelegramasTotalizadosAsync(authToken, partido.DistritoId!, partido.SeccionId!, categoria.Id);
|
|
||||||
|
|
||||||
if (listaTelegramasApi is { Count: > 0 })
|
|
||||||
{
|
|
||||||
// Usamos un DbContext propio para este bloque para asegurar que los cambios se guarden.
|
|
||||||
using var innerScope = _serviceProvider.CreateScope();
|
|
||||||
var innerDbContext = innerScope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
|
||||||
|
|
||||||
var idsYaEnDb = await innerDbContext.Telegramas
|
|
||||||
.Where(t => listaTelegramasApi.Contains(t.Id))
|
|
||||||
.Select(t => t.Id)
|
|
||||||
.ToListAsync(stoppingToken);
|
|
||||||
|
|
||||||
var nuevosTelegramasIds = listaTelegramasApi.Except(idsYaEnDb).ToList();
|
|
||||||
|
|
||||||
if (nuevosTelegramasIds.Any())
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Se encontraron {count} telegramas nuevos en '{partido}' para '{cat}'. Descargando...", nuevosTelegramasIds.Count, partido.Nombre, categoria.Nombre);
|
|
||||||
|
|
||||||
// Descargamos los archivos de uno en uno, con una pausa entre cada uno.
|
|
||||||
foreach (var mesaId in nuevosTelegramasIds)
|
|
||||||
{
|
|
||||||
if (stoppingToken.IsCancellationRequested) return;
|
|
||||||
|
|
||||||
var telegramaFile = await _apiService.GetTelegramaFileAsync(authToken, mesaId);
|
|
||||||
if (telegramaFile != null)
|
|
||||||
{
|
|
||||||
var nuevoTelegrama = new Telegrama
|
|
||||||
{
|
|
||||||
Id = telegramaFile.NombreArchivo,
|
|
||||||
AmbitoGeograficoId = partido.Id,
|
|
||||||
ContenidoBase64 = telegramaFile.Imagen,
|
|
||||||
FechaEscaneo = DateTime.Parse(telegramaFile.FechaEscaneo).ToUniversalTime(),
|
|
||||||
FechaTotalizacion = DateTime.Parse(telegramaFile.FechaTotalizacion).ToUniversalTime()
|
|
||||||
};
|
|
||||||
await innerDbContext.Telegramas.AddAsync(nuevoTelegrama, stoppingToken);
|
|
||||||
}
|
|
||||||
// PAUSA DELIBERADA: Esperamos un poco para no parecer un bot.
|
|
||||||
await Task.Delay(250, stoppingToken); // 250ms de espera = 4 peticiones/segundo máximo.
|
|
||||||
}
|
|
||||||
await innerDbContext.SaveChangesAsync(stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PAUSA DELIBERADA: Esperamos un poco entre cada consulta de lista de telegramas.
|
|
||||||
await Task.Delay(100, stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_logger.LogInformation("Sondeo de Telegramas completado.");
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Sondeo de telegramas cancelado.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Telegramas.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Obtiene y actualiza el resumen de votos a nivel provincial.
|
|
||||||
/// Esta versión mejorada utiliza una transacción para garantizar la consistencia de los datos.
|
|
||||||
/// </summary>
|
|
||||||
private async Task SondearResumenProvincialAsync(string authToken, CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
|
||||||
|
|
||||||
var provincia = await dbContext.AmbitosGeograficos.AsNoTracking().FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken);
|
|
||||||
if (provincia == null) return;
|
|
||||||
|
|
||||||
var resumen = await _apiService.GetResumenAsync(authToken, provincia.DistritoId!);
|
|
||||||
|
|
||||||
// --- CAMBIO CLAVE: Lógica de actualización robusta ---
|
|
||||||
// Solo procedemos si la respuesta de la API es válida Y contiene datos de votos positivos.
|
|
||||||
if (resumen?.ValoresTotalizadosPositivos is { Count: > 0 } nuevosVotos)
|
|
||||||
{
|
|
||||||
// Usamos una transacción explícita para asegurar que la operación sea atómica:
|
|
||||||
// O se completa todo (borrado e inserción), o no se hace nada.
|
|
||||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken);
|
|
||||||
|
|
||||||
// 1. Borramos los datos viejos.
|
|
||||||
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ResumenesVotos", stoppingToken);
|
|
||||||
|
|
||||||
// 2. Insertamos los nuevos datos.
|
|
||||||
foreach (var voto in nuevosVotos)
|
|
||||||
{
|
|
||||||
dbContext.ResumenesVotos.Add(new ResumenVoto
|
|
||||||
{
|
|
||||||
AmbitoGeograficoId = provincia.Id,
|
|
||||||
AgrupacionPoliticaId = voto.IdAgrupacion,
|
|
||||||
Votos = voto.Votos,
|
|
||||||
VotosPorcentaje = voto.VotosPorcentaje
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Guardamos los cambios y confirmamos la transacción.
|
|
||||||
await dbContext.SaveChangesAsync(stoppingToken);
|
|
||||||
await transaction.CommitAsync(stoppingToken);
|
|
||||||
|
|
||||||
_logger.LogInformation("Sondeo de Resumen Provincial completado. La tabla ha sido actualizada.");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Si la API no devuelve datos de votos, no hacemos NADA en la base de datos.
|
|
||||||
_logger.LogInformation("Sondeo de Resumen Provincial completado. No se recibieron datos nuevos, la tabla no fue modificada.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Ocurrió un error en el sondeo de Resumen Provincial.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Obtiene y actualiza el estado general del recuento a nivel provincial para CADA categoría electoral.
|
|
||||||
/// Esta versión es robusta: consulta dinámicamente las categorías, usa la clave primaria compuesta
|
|
||||||
/// de la base de datos y guarda todos los cambios en una única transacción al final.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="authToken">El token de autenticación válido para la sesión.</param>
|
|
||||||
/// <param name="stoppingToken">El token de cancelación para detener la operación.</param>
|
|
||||||
private async Task SondearEstadoRecuentoGeneralAsync(string authToken, CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// PASO 1: Crear un "scope" para obtener una instancia fresca de DbContext.
|
|
||||||
// Esto es una práctica recomendada para servicios de larga duración para evitar problemas de concurrencia.
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
|
||||||
|
|
||||||
// PASO 2: Obtener el ámbito geográfico de la Provincia.
|
|
||||||
// Necesitamos este objeto para obtener su 'DistritoId' ("02"), que es requerido por la API.
|
|
||||||
var provincia = await dbContext.AmbitosGeograficos
|
|
||||||
.AsNoTracking() // Optimización: Solo necesitamos leer datos, no modificarlos.
|
|
||||||
.FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken);
|
|
||||||
|
|
||||||
// Comprobación de seguridad: Si la sincronización inicial falló y no tenemos el registro de la provincia,
|
|
||||||
// no podemos continuar. Registramos una advertencia y salimos del método.
|
|
||||||
if (provincia == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("No se encontró el ámbito 'Provincia' (NivelId 10) en la BD. Omitiendo sondeo de estado general.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PASO 3: Obtener todas las categorías electorales disponibles desde nuestra base de datos.
|
|
||||||
// Esto hace que el método sea dinámico y no dependa de IDs fijos en el código.
|
|
||||||
var categoriasParaSondear = await dbContext.CategoriasElectorales
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(stoppingToken);
|
|
||||||
|
|
||||||
if (!categoriasParaSondear.Any())
|
|
||||||
{
|
|
||||||
_logger.LogWarning("No hay categorías en la BD para sondear el estado general del recuento.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Iniciando sondeo de Estado Recuento General para {count} categorías...", categoriasParaSondear.Count);
|
|
||||||
|
|
||||||
// PASO 4: Iterar sobre cada categoría para obtener su estado de recuento individual.
|
|
||||||
foreach (var categoria in categoriasParaSondear)
|
|
||||||
{
|
|
||||||
// Salimos limpiamente del bucle si la aplicación se está deteniendo.
|
|
||||||
if (stoppingToken.IsCancellationRequested) break;
|
|
||||||
|
|
||||||
// Llamamos a la API con el distrito y la CATEGORÍA ACTUAL del bucle.
|
|
||||||
var estadoDto = await _apiService.GetEstadoRecuentoGeneralAsync(authToken, provincia.DistritoId!, categoria.Id);
|
|
||||||
|
|
||||||
// Solo procedemos si la API devolvió datos válidos.
|
|
||||||
if (estadoDto != null)
|
|
||||||
{
|
|
||||||
// Lógica "Upsert" (Update or Insert):
|
|
||||||
// Buscamos un registro existente usando la CLAVE PRIMARIA COMPUESTA.
|
|
||||||
var registroDb = await dbContext.EstadosRecuentosGenerales.FindAsync(
|
|
||||||
new object[] { provincia.Id, categoria.Id },
|
|
||||||
cancellationToken: stoppingToken
|
|
||||||
);
|
|
||||||
|
|
||||||
// Si no se encuentra (FindAsync devuelve null), es un registro nuevo.
|
|
||||||
if (registroDb == null)
|
|
||||||
{
|
|
||||||
// Creamos una nueva instancia de la entidad.
|
|
||||||
registroDb = new EstadoRecuentoGeneral
|
|
||||||
{
|
|
||||||
AmbitoGeograficoId = provincia.Id,
|
|
||||||
CategoriaId = categoria.Id // Asignamos ambas partes de la clave primaria.
|
|
||||||
};
|
|
||||||
// Y la añadimos al ChangeTracker de EF para que la inserte en la BD.
|
|
||||||
dbContext.EstadosRecuentosGenerales.Add(registroDb);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mapeamos los datos del DTO de la API a nuestra entidad de base de datos.
|
|
||||||
// Esto se hace tanto para registros nuevos como para los existentes que se van a actualizar.
|
|
||||||
registroDb.MesasEsperadas = estadoDto.MesasEsperadas;
|
|
||||||
registroDb.MesasTotalizadas = estadoDto.MesasTotalizadas;
|
|
||||||
registroDb.MesasTotalizadasPorcentaje = estadoDto.MesasTotalizadasPorcentaje;
|
|
||||||
registroDb.CantidadElectores = estadoDto.CantidadElectores;
|
|
||||||
registroDb.CantidadVotantes = estadoDto.CantidadVotantes;
|
|
||||||
registroDb.ParticipacionPorcentaje = estadoDto.ParticipacionPorcentaje;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PASO 5: Guardar todos los cambios en la base de datos.
|
|
||||||
// Al llamar a SaveChangesAsync UNA SOLA VEZ fuera del bucle, EF Core agrupa
|
|
||||||
// todas las inserciones y actualizaciones en una única transacción eficiente.
|
|
||||||
await dbContext.SaveChangesAsync(stoppingToken);
|
|
||||||
_logger.LogInformation("Sondeo de Estado Recuento General completado para todas las categorías.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Capturamos cualquier excepción inesperada para que no detenga el worker y la registramos.
|
|
||||||
_logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Estado Recuento General.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Worker")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Worker")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68dce9415e165633856e4fae9b2d71cc07b4e2ff")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+19b37f73206d043982fc77f8c2359f2598889b64")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Worker")]
|
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Worker")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Worker")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Worker")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
Reference in New Issue
Block a user