Feat Workers Prioridades y Nivel Serilog
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
//Elecciones.Worker/CriticalDataWorker.cs
|
||||
using Elecciones.Database;
|
||||
using Elecciones.Database.Entities;
|
||||
using Elecciones.Infrastructure.Services;
|
||||
@@ -12,17 +13,20 @@ public class CriticalDataWorker : BackgroundService
|
||||
private readonly SharedTokenService _tokenService;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IElectoralApiService _apiService;
|
||||
private readonly WorkerConfigService _configService;
|
||||
|
||||
public CriticalDataWorker(
|
||||
ILogger<CriticalDataWorker> logger,
|
||||
SharedTokenService tokenService,
|
||||
IServiceProvider serviceProvider,
|
||||
IElectoralApiService apiService)
|
||||
IElectoralApiService apiService,
|
||||
WorkerConfigService configService)
|
||||
{
|
||||
_logger = logger;
|
||||
_tokenService = tokenService;
|
||||
_serviceProvider = serviceProvider;
|
||||
_apiService = apiService;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -50,9 +54,25 @@ public class CriticalDataWorker : BackgroundService
|
||||
continue;
|
||||
}
|
||||
|
||||
await SondearResultadosMunicipalesAsync(authToken, stoppingToken);
|
||||
await SondearResumenProvincialAsync(authToken, stoppingToken);
|
||||
await SondearEstadoRecuentoGeneralAsync(authToken, stoppingToken);
|
||||
var settings = await _configService.GetSettingsAsync();
|
||||
|
||||
if (settings.Prioridad == "Resultados" && settings.ResultadosActivado)
|
||||
{
|
||||
_logger.LogInformation("Ejecutando tareas de Resultados en alta prioridad.");
|
||||
await SondearResultadosMunicipalesAsync(authToken, stoppingToken);
|
||||
await SondearResumenProvincialAsync(authToken, stoppingToken);
|
||||
await SondearEstadoRecuentoGeneralAsync(authToken, stoppingToken);
|
||||
}
|
||||
else if (settings.Prioridad == "Telegramas" && settings.BajasActivado)
|
||||
{
|
||||
_logger.LogInformation("Ejecutando tareas de Baja Prioridad en alta prioridad.");
|
||||
await SondearProyeccionBancasAsync(authToken, stoppingToken);
|
||||
await SondearNuevosTelegramasAsync(authToken, stoppingToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Worker de alta prioridad inactivo según la configuración.");
|
||||
}
|
||||
|
||||
var cicloFin = DateTime.UtcNow;
|
||||
var duracionCiclo = cicloFin - cicloInicio;
|
||||
@@ -69,6 +89,252 @@ public class CriticalDataWorker : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sondea la proyección de bancas a nivel Provincial y por Sección Electoral.
|
||||
/// Esta versión es completamente robusta: maneja respuestas de API vacías o con fechas mal formadas,
|
||||
/// guarda la CategoriaId y usa una transacción atómica para la escritura en 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 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 de bancas.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Iniciando sondeo de Bancas a nivel Provincial y para {count} Secciones Electorales...", seccionesElectorales.Count);
|
||||
|
||||
var todasLasProyecciones = new List<ProyeccionBanca>();
|
||||
bool hasReceivedAnyNewData = false;
|
||||
|
||||
// Bucle para el nivel Provincial
|
||||
foreach (var categoria in categoriasDeBancas)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
var repartoBancasDto = await _apiService.GetBancasAsync(authToken, provincia.DistritoId!, null, categoria.Id);
|
||||
|
||||
if (repartoBancasDto?.RepartoBancas is { Count: > 0 } bancas)
|
||||
{
|
||||
hasReceivedAnyNewData = true;
|
||||
|
||||
// --- SEGURIDAD: Usar TryParse para la fecha ---
|
||||
DateTime fechaTotalizacion;
|
||||
if (!DateTime.TryParse(repartoBancasDto.FechaTotalizacion, out var parsedDate))
|
||||
{
|
||||
// Si la fecha es inválida (nula, vacía, mal formada), lo registramos y usamos la hora actual como respaldo.
|
||||
_logger.LogWarning("No se pudo parsear FechaTotalizacion ('{dateString}') para bancas provinciales. Usando la hora actual.", repartoBancasDto.FechaTotalizacion);
|
||||
fechaTotalizacion = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
fechaTotalizacion = parsedDate.ToUniversalTime();
|
||||
}
|
||||
|
||||
foreach (var banca in bancas)
|
||||
{
|
||||
todasLasProyecciones.Add(new ProyeccionBanca
|
||||
{
|
||||
AmbitoGeograficoId = provincia.Id,
|
||||
AgrupacionPoliticaId = banca.IdAgrupacion,
|
||||
NroBancas = banca.NroBancas,
|
||||
CategoriaId = categoria.Id,
|
||||
FechaTotalizacion = fechaTotalizacion
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 repartoBancasDto = await _apiService.GetBancasAsync(authToken, seccion.DistritoId!, seccion.SeccionProvincialId!, categoria.Id);
|
||||
|
||||
if (repartoBancasDto?.RepartoBancas is { Count: > 0 } bancas)
|
||||
{
|
||||
hasReceivedAnyNewData = true;
|
||||
|
||||
// --- APLICAMOS LA MISMA SEGURIDAD AQUÍ ---
|
||||
DateTime fechaTotalizacion;
|
||||
if (!DateTime.TryParse(repartoBancasDto.FechaTotalizacion, out var parsedDate))
|
||||
{
|
||||
_logger.LogWarning("No se pudo parsear FechaTotalizacion ('{dateString}') para bancas de sección. Usando la hora actual.", repartoBancasDto.FechaTotalizacion);
|
||||
fechaTotalizacion = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
fechaTotalizacion = parsedDate.ToUniversalTime();
|
||||
}
|
||||
|
||||
foreach (var banca in bancas)
|
||||
{
|
||||
todasLasProyecciones.Add(new ProyeccionBanca
|
||||
{
|
||||
AmbitoGeograficoId = seccion.Id,
|
||||
AgrupacionPoliticaId = banca.IdAgrupacion,
|
||||
NroBancas = banca.NroBancas,
|
||||
CategoriaId = categoria.Id,
|
||||
FechaTotalizacion = fechaTotalizacion
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasReceivedAnyNewData)
|
||||
{
|
||||
_logger.LogInformation("Se recibieron datos válidos de bancas. Procediendo a actualizar la base de datos...");
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken);
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ProyeccionesBancas", stoppingToken);
|
||||
await dbContext.ProyeccionesBancas.AddRangeAsync(todasLasProyecciones, stoppingToken);
|
||||
await dbContext.SaveChangesAsync(stoppingToken);
|
||||
await transaction.CommitAsync(stoppingToken);
|
||||
|
||||
_logger.LogInformation("La tabla de proyecciones ha sido actualizada con {count} registros.", todasLasProyecciones.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Sondeo de Bancas completado. No se encontraron datos nuevos de proyección, la tabla no fue modificada.");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Sondeo de bancas cancelado.");
|
||||
}
|
||||
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;
|
||||
|
||||
foreach (var partido in partidos)
|
||||
{
|
||||
foreach (var categoria in categorias)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) return;
|
||||
|
||||
var listaTelegramasApi = await _apiService.GetTelegramasTotalizadosAsync(authToken, partido.DistritoId!, partido.SeccionId!, categoria.Id);
|
||||
|
||||
if (listaTelegramasApi is { Count: > 0 })
|
||||
{
|
||||
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);
|
||||
|
||||
foreach (var mesaId in nuevosTelegramasIds)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) return;
|
||||
|
||||
var telegramaFile = await _apiService.GetTelegramaFileAsync(authToken, mesaId);
|
||||
if (telegramaFile != null)
|
||||
{
|
||||
// 1. Buscamos el AmbitoGeografico específico de la MESA que estamos procesando.
|
||||
var ambitoMesa = await innerDbContext.AmbitosGeograficos
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.MesaId == mesaId, stoppingToken);
|
||||
|
||||
// 2. Solo guardamos el telegrama si encontramos su ámbito de mesa correspondiente.
|
||||
if (ambitoMesa != null)
|
||||
{
|
||||
var nuevoTelegrama = new Telegrama
|
||||
{
|
||||
Id = telegramaFile.NombreArchivo,
|
||||
// 3. Usamos el ID del ÁMBITO DE LA MESA, no el del municipio.
|
||||
AmbitoGeograficoId = ambitoMesa.Id,
|
||||
ContenidoBase64 = telegramaFile.Imagen,
|
||||
FechaEscaneo = DateTime.Parse(telegramaFile.FechaEscaneo).ToUniversalTime(),
|
||||
FechaTotalizacion = DateTime.Parse(telegramaFile.FechaTotalizacion).ToUniversalTime()
|
||||
};
|
||||
await innerDbContext.Telegramas.AddAsync(nuevoTelegrama, stoppingToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No se encontró un ámbito geográfico para la mesa con MesaId {MesaId}. El telegrama no será guardado.", mesaId);
|
||||
}
|
||||
}
|
||||
await Task.Delay(250, stoppingToken);
|
||||
}
|
||||
await innerDbContext.SaveChangesAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SondearResultadosMunicipalesAsync(string authToken, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//Elecciones.Worker/LowPriorityDataWorker.cs
|
||||
using Elecciones.Database;
|
||||
using Elecciones.Database.Entities;
|
||||
using Elecciones.Infrastructure.Services;
|
||||
@@ -11,6 +12,7 @@ public class LowPriorityDataWorker : BackgroundService
|
||||
private readonly SharedTokenService _tokenService;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IElectoralApiService _apiService;
|
||||
private readonly WorkerConfigService _configService;
|
||||
|
||||
// Una variable para rastrear la tarea de telegramas, si está en ejecución.
|
||||
private Task? _telegramasTask;
|
||||
@@ -19,12 +21,14 @@ public class LowPriorityDataWorker : BackgroundService
|
||||
ILogger<LowPriorityDataWorker> logger,
|
||||
SharedTokenService tokenService,
|
||||
IServiceProvider serviceProvider,
|
||||
IElectoralApiService apiService)
|
||||
IElectoralApiService apiService,
|
||||
WorkerConfigService configService)
|
||||
{
|
||||
_logger = logger;
|
||||
_tokenService = tokenService;
|
||||
_serviceProvider = serviceProvider;
|
||||
_apiService = apiService;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -46,30 +50,25 @@ public class LowPriorityDataWorker : BackgroundService
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- LÓGICA DE EJECUCIÓN INDEPENDIENTE ---
|
||||
var settings = await _configService.GetSettingsAsync();
|
||||
|
||||
// 1. TAREA DE BANCAS: Siempre se ejecuta y se espera. Es rápida.
|
||||
_logger.LogInformation("Iniciando sondeo de Bancas...");
|
||||
await SondearProyeccionBancasAsync(authToken, stoppingToken);
|
||||
_logger.LogInformation("Sondeo de Bancas completado.");
|
||||
|
||||
// 2. TAREA DE TELEGRAMAS: "Dispara y Olvida" de forma segura.
|
||||
// Comprobamos si la tarea anterior de telegramas ya ha terminado.
|
||||
if (_telegramasTask == null || _telegramasTask.IsCompleted)
|
||||
if (settings.Prioridad == "Telegramas" && settings.ResultadosActivado)
|
||||
{
|
||||
_logger.LogInformation("Iniciando sondeo de Telegramas en segundo plano...");
|
||||
// Lanzamos la tarea de telegramas pero NO la esperamos con 'await'.
|
||||
// Guardamos una referencia a la tarea en nuestra variable de estado.
|
||||
_telegramasTask = SondearNuevosTelegramasAsync(authToken, stoppingToken);
|
||||
_logger.LogInformation("Ejecutando tareas de Resultados en baja prioridad.");
|
||||
await SondearResultadosMunicipalesAsync(authToken, stoppingToken);
|
||||
await SondearResumenProvincialAsync(authToken, stoppingToken);
|
||||
await SondearEstadoRecuentoGeneralAsync(authToken, stoppingToken);
|
||||
}
|
||||
else if (settings.Prioridad == "Resultados" && settings.BajasActivado)
|
||||
{
|
||||
_logger.LogInformation("Ejecutando tareas de Baja Prioridad en baja prioridad.");
|
||||
await SondearProyeccionBancasAsync(authToken, stoppingToken);
|
||||
await SondearNuevosTelegramasAsync(authToken, stoppingToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Si la descarga anterior todavía está en curso, nos saltamos este sondeo
|
||||
// para no acumular tareas y sobrecargar el sistema.
|
||||
_logger.LogInformation("El sondeo de telegramas anterior sigue en ejecución. Se omitirá en este ciclo.");
|
||||
_logger.LogInformation("Worker de baja prioridad inactivo según la configuración.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("--- Ciclo de Datos de Baja Prioridad completado. Esperando 5 minutos. ---");
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
@@ -81,6 +80,337 @@ public class LowPriorityDataWorker : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SondearResultadosMunicipalesAsync(string authToken, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
||||
|
||||
var municipiosASondear = await dbContext.AmbitosGeograficos
|
||||
.AsNoTracking()
|
||||
.Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
var todasLasCategorias = await dbContext.CategoriasElectorales
|
||||
.AsNoTracking()
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (!municipiosASondear.Any() || !todasLasCategorias.Any())
|
||||
{
|
||||
_logger.LogWarning("No se encontraron Partidos (NivelId 30) o Categorías para sondear resultados.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Iniciando sondeo de resultados para {m} municipios y {c} categorías...", municipiosASondear.Count, todasLasCategorias.Count);
|
||||
|
||||
foreach (var municipio in municipiosASondear)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
var tareasCategoria = todasLasCategorias.Select(async categoria =>
|
||||
{
|
||||
var resultados = await _apiService.GetResultadosAsync(authToken, municipio.DistritoId!, municipio.SeccionId!, null, categoria.Id);
|
||||
|
||||
if (resultados != null)
|
||||
{
|
||||
using var innerScope = _serviceProvider.CreateScope();
|
||||
var innerDbContext = innerScope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
||||
|
||||
// --- LLAMADA CORRECTA ---
|
||||
await GuardarResultadosDeAmbitoAsync(innerDbContext, municipio.Id, categoria.Id, resultados, stoppingToken);
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(tareasCategoria);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Ocurrió un error inesperado durante el sondeo de resultados municipales.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GuardarResultadosDeAmbitoAsync(
|
||||
EleccionesDbContext dbContext, int ambitoId, int categoriaId,
|
||||
Elecciones.Core.DTOs.ResultadosDto resultadosDto, CancellationToken stoppingToken)
|
||||
{
|
||||
var estadoRecuento = await dbContext.EstadosRecuentos.FindAsync(new object[] { ambitoId, categoriaId }, stoppingToken);
|
||||
|
||||
if (estadoRecuento == null)
|
||||
{
|
||||
estadoRecuento = new EstadoRecuento { AmbitoGeograficoId = ambitoId, CategoriaId = categoriaId };
|
||||
dbContext.EstadosRecuentos.Add(estadoRecuento);
|
||||
}
|
||||
|
||||
estadoRecuento.FechaTotalizacion = DateTime.Parse(resultadosDto.FechaTotalizacion).ToUniversalTime();
|
||||
estadoRecuento.MesasEsperadas = resultadosDto.EstadoRecuento.MesasEsperadas;
|
||||
estadoRecuento.MesasTotalizadas = resultadosDto.EstadoRecuento.MesasTotalizadas;
|
||||
estadoRecuento.MesasTotalizadasPorcentaje = resultadosDto.EstadoRecuento.MesasTotalizadasPorcentaje;
|
||||
estadoRecuento.CantidadElectores = resultadosDto.EstadoRecuento.CantidadElectores;
|
||||
estadoRecuento.CantidadVotantes = resultadosDto.EstadoRecuento.CantidadVotantes;
|
||||
estadoRecuento.ParticipacionPorcentaje = resultadosDto.EstadoRecuento.ParticipacionPorcentaje;
|
||||
|
||||
if (resultadosDto.ValoresTotalizadosOtros != null)
|
||||
{
|
||||
estadoRecuento.VotosEnBlanco = resultadosDto.ValoresTotalizadosOtros.VotosEnBlanco;
|
||||
estadoRecuento.VotosEnBlancoPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosEnBlancoPorcentaje;
|
||||
estadoRecuento.VotosNulos = resultadosDto.ValoresTotalizadosOtros.VotosNulos;
|
||||
estadoRecuento.VotosNulosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosNulosPorcentaje;
|
||||
estadoRecuento.VotosRecurridos = resultadosDto.ValoresTotalizadosOtros.VotosRecurridos;
|
||||
estadoRecuento.VotosRecurridosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosRecurridosPorcentaje;
|
||||
}
|
||||
|
||||
foreach (var votoPositivoDto in resultadosDto.ValoresTotalizadosPositivos)
|
||||
{
|
||||
var resultadoVoto = await dbContext.ResultadosVotos.FirstOrDefaultAsync(
|
||||
rv => rv.AmbitoGeograficoId == ambitoId &&
|
||||
rv.CategoriaId == categoriaId &&
|
||||
rv.AgrupacionPoliticaId == votoPositivoDto.IdAgrupacion,
|
||||
stoppingToken
|
||||
);
|
||||
|
||||
if (resultadoVoto == null)
|
||||
{
|
||||
resultadoVoto = new ResultadoVoto
|
||||
{
|
||||
AmbitoGeograficoId = ambitoId,
|
||||
CategoriaId = categoriaId,
|
||||
AgrupacionPoliticaId = votoPositivoDto.IdAgrupacion
|
||||
};
|
||||
dbContext.ResultadosVotos.Add(resultadoVoto);
|
||||
}
|
||||
resultadoVoto.CantidadVotos = votoPositivoDto.Votos;
|
||||
resultadoVoto.PorcentajeVotos = votoPositivoDto.VotosPorcentaje;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(stoppingToken);
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
_logger.LogError(ex, "DbUpdateException al guardar resultados para AmbitoId {ambitoId} y CategoriaId {categoriaId}", ambitoId, categoriaId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene y actualiza el resumen de votos y el estado del recuento a nivel provincial.
|
||||
/// Esta versión actualizada guarda tanto los votos por agrupación (en ResumenesVotos)
|
||||
/// como el estado general del recuento, incluyendo la fecha de totalización (en EstadosRecuentosGenerales),
|
||||
/// asegurando que toda la operación sea atómica mediante una transacción de base de datos.
|
||||
/// </summary>
|
||||
/// <param name="authToken">El token de autenticación válido para la sesión.</param>
|
||||
/// <param name="stoppingToken">El token de cancelación para detener la operación.</param>
|
||||
private async Task SondearResumenProvincialAsync(string authToken, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Creamos un scope de DbContext para esta operación.
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
||||
|
||||
// Obtenemos el registro de la Provincia (NivelId 10).
|
||||
var provincia = await dbContext.AmbitosGeograficos
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken);
|
||||
|
||||
// Si no encontramos el ámbito de la provincia, no podemos continuar.
|
||||
if (provincia == null)
|
||||
{
|
||||
_logger.LogWarning("No se encontró el ámbito 'Provincia' (NivelId 10) para el sondeo de resumen.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Llamamos a la API para obtener el resumen de datos provincial.
|
||||
var resumenDto = await _apiService.GetResumenAsync(authToken, provincia.DistritoId!);
|
||||
|
||||
// Solo procedemos si la API devolvió una respuesta válida y no nula.
|
||||
if (resumenDto != null)
|
||||
{
|
||||
// Iniciamos una transacción explícita. Esto garantiza que todas las operaciones de base de datos
|
||||
// dentro de este bloque (el DELETE, los INSERTs y los UPDATEs) se completen con éxito,
|
||||
// o si algo falla, se reviertan todas, manteniendo la consistencia de los datos.
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken);
|
||||
|
||||
// --- 1. ACTUALIZAR LA TABLA 'ResumenesVotos' ---
|
||||
|
||||
// Verificamos si la respuesta contiene una lista de votos positivos.
|
||||
if (resumenDto.ValoresTotalizadosPositivos is { Count: > 0 } nuevosVotos)
|
||||
{
|
||||
// Estrategia "Borrar y Reemplazar": vaciamos la tabla antes de insertar los nuevos datos.
|
||||
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ResumenesVotos", stoppingToken);
|
||||
|
||||
// Añadimos cada nuevo registro de voto al DbContext.
|
||||
foreach (var voto in nuevosVotos)
|
||||
{
|
||||
dbContext.ResumenesVotos.Add(new ResumenVoto
|
||||
{
|
||||
AmbitoGeograficoId = provincia.Id,
|
||||
AgrupacionPoliticaId = voto.IdAgrupacion,
|
||||
Votos = voto.Votos,
|
||||
VotosPorcentaje = voto.VotosPorcentaje
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. ACTUALIZAR LA TABLA 'EstadosRecuentosGenerales' ---
|
||||
|
||||
// El endpoint de Resumen no especifica una categoría, por lo que aplicamos sus datos de estado de recuento
|
||||
// a todas las categorías que tenemos en nuestra base de datos.
|
||||
var todasLasCategorias = await dbContext.CategoriasElectorales.AsNoTracking().ToListAsync(stoppingToken);
|
||||
foreach (var categoria in todasLasCategorias)
|
||||
{
|
||||
// Buscamos el registro existente usando la clave primaria compuesta.
|
||||
var registroDb = await dbContext.EstadosRecuentosGenerales.FindAsync(new object[] { provincia.Id, categoria.Id }, stoppingToken);
|
||||
|
||||
// Si no existe, lo creamos.
|
||||
if (registroDb == null)
|
||||
{
|
||||
registroDb = new EstadoRecuentoGeneral { AmbitoGeograficoId = provincia.Id, CategoriaId = categoria.Id };
|
||||
dbContext.EstadosRecuentosGenerales.Add(registroDb);
|
||||
}
|
||||
|
||||
// Parseamos la fecha de forma segura para evitar errores con cadenas vacías o nulas.
|
||||
if (DateTime.TryParse(resumenDto.FechaTotalizacion, out var parsedDate))
|
||||
{
|
||||
registroDb.FechaTotalizacion = parsedDate.ToUniversalTime();
|
||||
}
|
||||
|
||||
// Mapeamos el resto de los datos del estado del recuento.
|
||||
registroDb.MesasEsperadas = resumenDto.EstadoRecuento.MesasEsperadas;
|
||||
registroDb.MesasTotalizadas = resumenDto.EstadoRecuento.MesasTotalizadas;
|
||||
registroDb.MesasTotalizadasPorcentaje = resumenDto.EstadoRecuento.MesasTotalizadasPorcentaje;
|
||||
registroDb.CantidadElectores = resumenDto.EstadoRecuento.CantidadElectores;
|
||||
registroDb.CantidadVotantes = resumenDto.EstadoRecuento.CantidadVotantes;
|
||||
registroDb.ParticipacionPorcentaje = resumenDto.EstadoRecuento.ParticipacionPorcentaje;
|
||||
}
|
||||
|
||||
// 3. CONFIRMAR Y GUARDAR
|
||||
// Guardamos todos los cambios preparados (DELETEs, INSERTs, UPDATEs) en la base de datos.
|
||||
await dbContext.SaveChangesAsync(stoppingToken);
|
||||
// Confirmamos la transacción para hacer los cambios permanentes.
|
||||
await transaction.CommitAsync(stoppingToken);
|
||||
|
||||
_logger.LogInformation("Sondeo de Resumen Provincial completado. Las tablas han sido actualizadas.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Si la API no devolvió datos (ej. devuelve null), no hacemos nada en la BD.
|
||||
_logger.LogInformation("Sondeo de Resumen Provincial completado. No se recibieron datos nuevos.");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Sondeo de resumen provincial cancelado.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Capturamos cualquier otro error inesperado para que el worker no se detenga.
|
||||
_logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Resumen Provincial.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene y actualiza el estado general del recuento a nivel provincial para CADA categoría electoral.
|
||||
/// Esta versión es robusta: consulta dinámicamente las categorías, usa la clave primaria compuesta
|
||||
/// de la base de datos y guarda todos los cambios en una única transacción al final.
|
||||
/// </summary>
|
||||
/// <param name="authToken">El token de autenticación válido para la sesión.</param>
|
||||
/// <param name="stoppingToken">El token de cancelación para detener la operación.</param>
|
||||
private async Task SondearEstadoRecuentoGeneralAsync(string authToken, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// PASO 1: Crear un "scope" para obtener una instancia fresca de DbContext.
|
||||
// Esto es una práctica recomendada para servicios de larga duración para evitar problemas de concurrencia.
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<EleccionesDbContext>();
|
||||
|
||||
// PASO 2: Obtener el ámbito geográfico de la Provincia.
|
||||
// Necesitamos este objeto para obtener su 'DistritoId' ("02"), que es requerido por la API.
|
||||
var provincia = await dbContext.AmbitosGeograficos
|
||||
.AsNoTracking() // Optimización: Solo necesitamos leer datos, no modificarlos.
|
||||
.FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken);
|
||||
|
||||
// Comprobación de seguridad: Si la sincronización inicial falló y no tenemos el registro de la provincia,
|
||||
// no podemos continuar. Registramos una advertencia y salimos del método.
|
||||
if (provincia == null)
|
||||
{
|
||||
_logger.LogWarning("No se encontró el ámbito 'Provincia' (NivelId 10) en la BD. Omitiendo sondeo de estado general.");
|
||||
return;
|
||||
}
|
||||
|
||||
// PASO 3: Obtener todas las categorías electorales disponibles desde nuestra base de datos.
|
||||
// Esto hace que el método sea dinámico y no dependa de IDs fijos en el código.
|
||||
var categoriasParaSondear = await dbContext.CategoriasElectorales
|
||||
.AsNoTracking()
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (!categoriasParaSondear.Any())
|
||||
{
|
||||
_logger.LogWarning("No hay categorías en la BD para sondear el estado general del recuento.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Iniciando sondeo de Estado Recuento General para {count} categorías...", categoriasParaSondear.Count);
|
||||
|
||||
// PASO 4: Iterar sobre cada categoría para obtener su estado de recuento individual.
|
||||
foreach (var categoria in categoriasParaSondear)
|
||||
{
|
||||
// Salimos limpiamente del bucle si la aplicación se está deteniendo.
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
// Llamamos a la API con el distrito y la CATEGORÍA ACTUAL del bucle.
|
||||
var estadoDto = await _apiService.GetEstadoRecuentoGeneralAsync(authToken, provincia.DistritoId!, categoria.Id);
|
||||
|
||||
// Solo procedemos si la API devolvió datos válidos.
|
||||
if (estadoDto != null)
|
||||
{
|
||||
// Lógica "Upsert" (Update or Insert):
|
||||
// Buscamos un registro existente usando la CLAVE PRIMARIA COMPUESTA.
|
||||
var registroDb = await dbContext.EstadosRecuentosGenerales.FindAsync(
|
||||
new object[] { provincia.Id, categoria.Id },
|
||||
cancellationToken: stoppingToken
|
||||
);
|
||||
|
||||
// Si no se encuentra (FindAsync devuelve null), es un registro nuevo.
|
||||
if (registroDb == null)
|
||||
{
|
||||
// Creamos una nueva instancia de la entidad.
|
||||
registroDb = new EstadoRecuentoGeneral
|
||||
{
|
||||
AmbitoGeograficoId = provincia.Id,
|
||||
CategoriaId = categoria.Id // Asignamos ambas partes de la clave primaria.
|
||||
};
|
||||
// Y la añadimos al ChangeTracker de EF para que la inserte en la BD.
|
||||
dbContext.EstadosRecuentosGenerales.Add(registroDb);
|
||||
}
|
||||
|
||||
// Mapeamos los datos del DTO de la API a nuestra entidad de base de datos.
|
||||
// Esto se hace tanto para registros nuevos como para los existentes que se van a actualizar.
|
||||
registroDb.MesasEsperadas = estadoDto.MesasEsperadas;
|
||||
registroDb.MesasTotalizadas = estadoDto.MesasTotalizadas;
|
||||
registroDb.MesasTotalizadasPorcentaje = estadoDto.MesasTotalizadasPorcentaje;
|
||||
registroDb.CantidadElectores = estadoDto.CantidadElectores;
|
||||
registroDb.CantidadVotantes = estadoDto.CantidadVotantes;
|
||||
registroDb.ParticipacionPorcentaje = estadoDto.ParticipacionPorcentaje;
|
||||
}
|
||||
}
|
||||
|
||||
// PASO 5: Guardar todos los cambios en la base de datos.
|
||||
// Al llamar a SaveChangesAsync UNA SOLA VEZ fuera del bucle, EF Core agrupa
|
||||
// todas las inserciones y actualizaciones en una única transacción eficiente.
|
||||
await dbContext.SaveChangesAsync(stoppingToken);
|
||||
_logger.LogInformation("Sondeo de Estado Recuento General completado para todas las categorías.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Capturamos cualquier excepción inesperada para que no detenga el worker y la registramos.
|
||||
_logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Estado Recuento General.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
@@ -286,7 +616,7 @@ public class LowPriorityDataWorker : BackgroundService
|
||||
{
|
||||
hasReceivedAnyNewData = true;
|
||||
|
||||
// --- CORRECCIÓN DE SEGURIDAD: Usar TryParse para la fecha ---
|
||||
// --- SEGURIDAD: Usar TryParse para la fecha ---
|
||||
DateTime fechaTotalizacion;
|
||||
if (!DateTime.TryParse(repartoBancasDto.FechaTotalizacion, out var parsedDate))
|
||||
{
|
||||
@@ -326,7 +656,7 @@ public class LowPriorityDataWorker : BackgroundService
|
||||
{
|
||||
hasReceivedAnyNewData = true;
|
||||
|
||||
// --- APLICAMOS LA MISMA CORRECCIÓN DE SEGURIDAD AQUÍ ---
|
||||
// --- APLICAMOS SEGURIDAD AQUÍ ---
|
||||
DateTime fechaTotalizacion;
|
||||
if (!DateTime.TryParse(repartoBancasDto.FechaTotalizacion, out var parsedDate))
|
||||
{
|
||||
@@ -439,7 +769,6 @@ public class LowPriorityDataWorker : BackgroundService
|
||||
var telegramaFile = await _apiService.GetTelegramaFileAsync(authToken, mesaId);
|
||||
if (telegramaFile != null)
|
||||
{
|
||||
// --- INICIO DE LA CORRECCIÓN ---
|
||||
// 1. Buscamos el AmbitoGeografico específico de la MESA que estamos procesando.
|
||||
var ambitoMesa = await innerDbContext.AmbitosGeograficos
|
||||
.AsNoTracking()
|
||||
@@ -463,7 +792,6 @@ public class LowPriorityDataWorker : BackgroundService
|
||||
{
|
||||
_logger.LogWarning("No se encontró un ámbito geográfico para la mesa con MesaId {MesaId}. El telegrama no será guardado.", mesaId);
|
||||
}
|
||||
// --- FIN DE LA CORRECCIÓN ---
|
||||
}
|
||||
await Task.Delay(250, stoppingToken);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//Elecciones.Worker/Program.cs
|
||||
using Elecciones.Database;
|
||||
using Elecciones.Infrastructure.Services;
|
||||
using Elecciones.Worker;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System.Net;
|
||||
using Serilog;
|
||||
using System.Net.Http;
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication;
|
||||
using Polly;
|
||||
@@ -21,12 +19,20 @@ Log.Information("Iniciando Elecciones.Worker Host...");
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.AddSerilog(config =>
|
||||
config
|
||||
// 1. Registra el servicio del interruptor como siempre.
|
||||
builder.Services.AddSingleton<LoggingSwitchService>();
|
||||
|
||||
// 2. Configura Serilog usando AddSerilog.
|
||||
builder.Services.AddSerilog((services, configuration) => {
|
||||
var loggingSwitch = services.GetRequiredService<LoggingSwitchService>();
|
||||
configuration
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext()
|
||||
.MinimumLevel.ControlledBy(loggingSwitch.LevelSwitch)
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File("logs/worker-.log", rollingInterval: RollingInterval.Day));
|
||||
.WriteTo.File("logs/worker-.log", rollingInterval: RollingInterval.Day);
|
||||
});
|
||||
|
||||
// --- Configuración de Servicios ---
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
||||
@@ -93,16 +99,47 @@ builder.Services.AddSingleton<RateLimiter>(sp =>
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
|
||||
}));
|
||||
|
||||
builder.Services.AddScoped<IElectoralApiService, ElectoralApiService>();
|
||||
builder.Services.AddScoped<IElectoralApiService, ElectoralApiService>();
|
||||
|
||||
// Registramos el servicio de token como un Singleton para que sea compartido.
|
||||
builder.Services.AddSingleton<SharedTokenService>();
|
||||
// Registramos el servicio de configuraciones de workers como un Singleton para que sea compartido.
|
||||
builder.Services.AddSingleton<WorkerConfigService>();
|
||||
|
||||
// Registramos ambos workers. El framework se encargará de iniciarlos y detenerlos.
|
||||
builder.Services.AddHostedService<CriticalDataWorker>();
|
||||
builder.Services.AddHostedService<LowPriorityDataWorker>();
|
||||
//builder.Services.AddHostedService<Worker>();
|
||||
|
||||
// --- LÓGICA PARA LEER EL NIVEL DE LOGGING AL INICIO ---
|
||||
// Creamos un scope temporal para leer la configuración de la BD
|
||||
using (var scope = builder.Services.BuildServiceProvider().CreateScope())
|
||||
{
|
||||
var services = scope.ServiceProvider;
|
||||
try
|
||||
{
|
||||
var dbContext = services.GetRequiredService<EleccionesDbContext>();
|
||||
var loggingSwitchService = services.GetRequiredService<LoggingSwitchService>();
|
||||
|
||||
// Buscamos el nivel de logging guardado en la BD
|
||||
var logLevelConfig = await dbContext.Configuraciones
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Clave == "Logging_Level");
|
||||
|
||||
if (logLevelConfig != null)
|
||||
{
|
||||
// Si lo encontramos, lo aplicamos al interruptor
|
||||
loggingSwitchService.SetLoggingLevel(logLevelConfig.Valor);
|
||||
Console.WriteLine($"--> Nivel de logging inicial establecido desde la BD a: {logLevelConfig.Valor}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Si hay un error (ej. la BD no está disponible al arrancar), se usará el nivel por defecto 'Information'.
|
||||
Console.WriteLine($"--> No se pudo establecer el nivel de logging desde la BD: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
try
|
||||
|
||||
@@ -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+55954e18a797dce22f76f00b645832f361d97362")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+f384a640f36be1289d652dc85e78ebdcef30968a")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Worker")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Worker")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -180,6 +180,9 @@
|
||||
"projectReferences": {
|
||||
"E:\\Elecciones-2025\\Elecciones-Web\\src\\Elecciones.Core\\Elecciones.Core.csproj": {
|
||||
"projectPath": "E:\\Elecciones-2025\\Elecciones-Web\\src\\Elecciones.Core\\Elecciones.Core.csproj"
|
||||
},
|
||||
"E:\\Elecciones-2025\\Elecciones-Web\\src\\Elecciones.Database\\Elecciones.Database.csproj": {
|
||||
"projectPath": "E:\\Elecciones-2025\\Elecciones-Web\\src\\Elecciones.Database\\Elecciones.Database.csproj"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +211,10 @@
|
||||
"target": "Package",
|
||||
"version": "[9.0.8, )"
|
||||
},
|
||||
"Serilog": {
|
||||
"target": "Package",
|
||||
"version": "[4.3.0, )"
|
||||
},
|
||||
"System.Threading.RateLimiting": {
|
||||
"target": "Package",
|
||||
"version": "[9.0.8, )"
|
||||
|
||||
Reference in New Issue
Block a user