433 lines
19 KiB
C#
433 lines
19 KiB
C#
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)
|
|
} |