Try Separación de Metodos

This commit is contained in:
2025-08-20 16:58:18 -03:00
parent 19b37f7320
commit c967da919a
7 changed files with 936 additions and 851 deletions

View File

@@ -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;
}
}

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Infrastructure")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68dce9415e165633856e4fae9b2d71cc07b4e2ff")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+19b37f73206d043982fc77f8c2359f2598889b64")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Infrastructure")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Infrastructure")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View 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)
}

View 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)
}

View File

@@ -104,7 +104,13 @@ builder.Services.AddHttpClient("ElectoralApiClient", client =>
*/
builder.Services.AddScoped<IElectoralApiService, ElectoralApiService>();
builder.Services.AddHostedService<Worker>();
// Registramos el servicio de token como un Singleton para que sea compartido.
builder.Services.AddSingleton<SharedTokenService>();
// Registramos ambos workers. El framework se encargará de iniciarlos y detenerlos.
builder.Services.AddHostedService<CriticalDataWorker>();
builder.Services.AddHostedService<LowPriorityDataWorker>();
//builder.Services.AddHostedService<Worker>();
var host = builder.Build();

View File

@@ -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.");
}
}
}

View File

@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Worker")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68dce9415e165633856e4fae9b2d71cc07b4e2ff")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+19b37f73206d043982fc77f8c2359f2598889b64")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Worker")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Worker")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]