//Elecciones.Worker/LowPriorityDataWorker.cs using Elecciones.Database; using Elecciones.Database.Entities; using Elecciones.Infrastructure.Services; using Microsoft.EntityFrameworkCore; namespace Elecciones.Worker; public class LowPriorityDataWorker : BackgroundService { private const int EleccionId = 2; private readonly ILogger _logger; 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; public LowPriorityDataWorker( ILogger logger, SharedTokenService tokenService, IServiceProvider serviceProvider, IElectoralApiService apiService, WorkerConfigService configService) { _logger = logger; _tokenService = tokenService; _serviceProvider = serviceProvider; _apiService = apiService; _configService = configService; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("Worker de Baja Prioridad iniciado."); // La sincronización inicial sigue siendo un paso de bloqueo, es necesario. 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; } var settings = await _configService.GetSettingsAsync(); if (settings.Prioridad == "Telegramas" && settings.ResultadosActivado) { _logger.LogInformation("Ejecutando tareas de Resultados en baja prioridad."); await SondearEstadoRecuentoGeneralAsync(authToken, stoppingToken); await SondearResultadosMunicipalesAsync(authToken, stoppingToken); await SondearResumenProvincialAsync(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 { _logger.LogInformation("Worker de baja prioridad inactivo según la configuración."); } try { await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); } catch (TaskCanceledException) { break; } } } private async Task SondearResultadosMunicipalesAsync(string authToken, CancellationToken stoppingToken) { try { using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); 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(); 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 { EleccionId = EleccionId, 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; estadoRecuento.VotosComando = resultadosDto.ValoresTotalizadosOtros.VotosComando; estadoRecuento.VotosComandoPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosComandoPorcentaje; estadoRecuento.VotosImpugnados = resultadosDto.ValoresTotalizadosOtros.VotosImpugnados; estadoRecuento.VotosImpugnadosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosImpugnadosPorcentaje; } foreach (var votoPositivoDto in resultadosDto.ValoresTotalizadosPositivos) { // PASO 1: VERIFICAR SI LA AGRUPACIÓN YA EXISTE EN NUESTRA BD var agrupacion = await dbContext.AgrupacionesPoliticas.FindAsync(votoPositivoDto.IdAgrupacion); // PASO 2: SI NO EXISTE, LA CREAMOS "SOBRE LA MARCHA" if (agrupacion == null) { _logger.LogWarning("Agrupación con ID {AgrupacionId} ('{Nombre}') no encontrada en el catálogo local. Creándola desde los datos de resultados.", votoPositivoDto.IdAgrupacion, votoPositivoDto.NombreAgrupacion); agrupacion = new AgrupacionPolitica { Id = votoPositivoDto.IdAgrupacion, Nombre = votoPositivoDto.NombreAgrupacion, // El IdTelegrama puede ser nulo, usamos el operador '??' para asignar un string vacío si es así. IdTelegrama = votoPositivoDto.IdAgrupacionTelegrama ?? string.Empty }; await dbContext.AgrupacionesPoliticas.AddAsync(agrupacion, stoppingToken); // No es necesario llamar a SaveChangesAsync aquí, se hará al final. } // PASO 3: CONTINUAR CON LA LÓGICA DE GUARDADO DEL VOTO var resultadoVoto = await dbContext.ResultadosVotos.FirstOrDefaultAsync( rv => rv.AmbitoGeograficoId == ambitoId && rv.CategoriaId == categoriaId && rv.AgrupacionPoliticaId == votoPositivoDto.IdAgrupacion, stoppingToken ); if (resultadoVoto == null) { resultadoVoto = new ResultadoVoto { EleccionId = EleccionId, 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); } } /// /// Obtiene y actualiza el resumen de votos y el estado del recuento a nivel provincial para CADA categoría. /// Este método itera sobre todas las provincias y categorías, obteniendo sus resultados consolidados /// y guardándolos en las tablas 'ResumenesVotos' y 'EstadosRecuentosGenerales'. /// private async Task SondearResumenProvincialAsync(string authToken, CancellationToken stoppingToken) { try { _logger.LogInformation("Iniciando sondeo de Resúmenes Provinciales..."); using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var provinciasASondear = await dbContext.AmbitosGeograficos .AsNoTracking() .Where(a => a.NivelId == 10 && a.DistritoId != null) .ToListAsync(stoppingToken); var todasLasCategorias = await dbContext.CategoriasElectorales .AsNoTracking() .ToListAsync(stoppingToken); if (!provinciasASondear.Any() || !todasLasCategorias.Any()) { _logger.LogWarning("No se encontraron Provincias o Categorías para sondear resúmenes."); return; } foreach (var provincia in provinciasASondear) { if (stoppingToken.IsCancellationRequested) break; foreach (var categoria in todasLasCategorias) { if (stoppingToken.IsCancellationRequested) break; // Usamos GetResultados sin seccionId/municipioId para obtener el resumen del distrito. var resultadosDto = await _apiService.GetResultadosAsync(authToken, provincia.DistritoId!, null, null, categoria.Id); if (resultadosDto?.ValoresTotalizadosPositivos is { Count: > 0 } nuevosVotos) { // Usamos una transacción para asegurar que el borrado y la inserción sean atómicos. await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken); // A. Borrar los resúmenes viejos SOLO para esta provincia y categoría. await dbContext.ResumenesVotos .Where(rv => rv.AmbitoGeograficoId == provincia.Id && rv.CategoriaId == categoria.Id) .ExecuteDeleteAsync(stoppingToken); // B. Añadir los nuevos resúmenes. foreach (var voto in nuevosVotos) { dbContext.ResumenesVotos.Add(new ResumenVoto { EleccionId = EleccionId, AmbitoGeograficoId = provincia.Id, CategoriaId = categoria.Id, AgrupacionPoliticaId = voto.IdAgrupacion, Votos = voto.Votos, VotosPorcentaje = voto.VotosPorcentaje }); } // C. Guardar los cambios en la tabla ResumenesVotos. await dbContext.SaveChangesAsync(stoppingToken); // No es necesario actualizar EstadosRecuentosGenerales aquí, // ya que el método SondearEstadoRecuentoGeneralAsync se encarga de eso // de forma más específica y eficiente. await transaction.CommitAsync(stoppingToken); } } // Fin bucle categorías } // Fin bucle provincias _logger.LogInformation("Sondeo de Resúmenes Provinciales completado."); } catch (OperationCanceledException) { _logger.LogInformation("Sondeo de resúmenes provinciales cancelado."); } catch (Exception ex) { _logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Resúmenes Provinciales."); } } /// /// 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. /// /// El token de autenticación válido para la sesión. /// El token de cancelación para detener la operación. private async Task SondearEstadoRecuentoGeneralAsync(string authToken, CancellationToken stoppingToken) { try { using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var provinciasASondear = await dbContext.AmbitosGeograficos .AsNoTracking() .Where(a => a.NivelId == 10 && a.DistritoId != null) .ToListAsync(stoppingToken); // Busca NivelId 1 (País) o 0 como fallback. var ambitoNacional = await dbContext.AmbitosGeograficos .AsNoTracking() .FirstOrDefaultAsync(a => a.NivelId == 1 || a.NivelId == 0, stoppingToken); var categoriasParaSondear = await dbContext.CategoriasElectorales .AsNoTracking() .ToListAsync(stoppingToken); if (!provinciasASondear.Any() || !categoriasParaSondear.Any()) { _logger.LogWarning("No se encontraron Provincias o Categorías para sondear estado general."); return; } _logger.LogInformation("Iniciando sondeo de Estado Recuento General para {provCount} provincias, el total nacional y {catCount} categorías...", provinciasASondear.Count, categoriasParaSondear.Count); // Sondeo a nivel provincial foreach (var provincia in provinciasASondear) { if (stoppingToken.IsCancellationRequested) break; foreach (var categoria in categoriasParaSondear) { if (stoppingToken.IsCancellationRequested) break; var estadoDto = await _apiService.GetEstadoRecuentoGeneralAsync(authToken, provincia.DistritoId!, categoria.Id); if (estadoDto != null) { var registroDb = await dbContext.EstadosRecuentosGenerales.FindAsync(new object[] { provincia.Id, categoria.Id }, stoppingToken); if (registroDb == null) { registroDb = new EstadoRecuentoGeneral { EleccionId = EleccionId, AmbitoGeograficoId = provincia.Id, CategoriaId = categoria.Id }; dbContext.EstadosRecuentosGenerales.Add(registroDb); } registroDb.FechaTotalizacion = DateTime.UtcNow; registroDb.MesasEsperadas = estadoDto.MesasEsperadas; registroDb.MesasTotalizadas = estadoDto.MesasTotalizadas; registroDb.MesasTotalizadasPorcentaje = estadoDto.MesasTotalizadasPorcentaje; registroDb.CantidadElectores = estadoDto.CantidadElectores; registroDb.CantidadVotantes = estadoDto.CantidadVotantes; registroDb.ParticipacionPorcentaje = estadoDto.ParticipacionPorcentaje; } } } // Bloque para el sondeo a nivel nacional if (ambitoNacional != null && !stoppingToken.IsCancellationRequested) { _logger.LogInformation("Sondeando totales a nivel Nacional (Ambito ID: {ambitoId})...", ambitoNacional.Id); foreach (var categoria in categoriasParaSondear) { if (stoppingToken.IsCancellationRequested) break; var estadoNacionalDto = await _apiService.GetEstadoRecuentoGeneralAsync(authToken, "", categoria.Id); if (estadoNacionalDto != null) { var registroNacionalDb = await dbContext.EstadosRecuentosGenerales.FindAsync(new object[] { ambitoNacional.Id, categoria.Id }, stoppingToken); if (registroNacionalDb == null) { registroNacionalDb = new EstadoRecuentoGeneral { EleccionId = EleccionId, AmbitoGeograficoId = ambitoNacional.Id, CategoriaId = categoria.Id }; dbContext.EstadosRecuentosGenerales.Add(registroNacionalDb); } registroNacionalDb.FechaTotalizacion = DateTime.UtcNow; registroNacionalDb.MesasEsperadas = estadoNacionalDto.MesasEsperadas; registroNacionalDb.MesasTotalizadas = estadoNacionalDto.MesasTotalizadas; registroNacionalDb.MesasTotalizadasPorcentaje = estadoNacionalDto.MesasTotalizadasPorcentaje; registroNacionalDb.CantidadElectores = estadoNacionalDto.CantidadElectores; registroNacionalDb.CantidadVotantes = estadoNacionalDto.CantidadVotantes; registroNacionalDb.ParticipacionPorcentaje = estadoNacionalDto.ParticipacionPorcentaje; _logger.LogInformation("Datos nacionales para categoría '{catNombre}' actualizados.", categoria.Nombre); } } } else if (ambitoNacional == null) { _logger.LogWarning("No se encontró el ámbito geográfico para el Nivel Nacional (NivelId 1 o 0). No se pueden capturar los totales del país."); } // Guardar todos los cambios if (dbContext.ChangeTracker.HasChanges()) { await dbContext.SaveChangesAsync(stoppingToken); _logger.LogInformation("Sondeo de Estado Recuento General completado. Se han guardado los cambios en la base de datos."); } else { _logger.LogInformation("Sondeo de Estado Recuento General completado. No se detectaron cambios."); } } catch (OperationCanceledException) { _logger.LogInformation("Sondeo de Estado Recuento General cancelado."); } catch (Exception ex) { _logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Estado Recuento General."); } } /// /// 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. /// /// El token de cancelación para detener la operación. private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingToken) { try { _logger.LogInformation("Iniciando sincronización de catálogos maestros..."); var authToken = await _tokenService.GetValidAuthTokenAsync(stoppingToken); if (string.IsNullOrEmpty(authToken)) { _logger.LogError("No se pudo obtener token para la sincronización de catálogos."); return; } using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); // 1. SINCRONIZAR CATEGORÍAS 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."); return; } var distinctCategorias = categoriasApi.GroupBy(c => c.CategoriaId).Select(g => g.First()).OrderBy(c => c.Orden).ToList(); 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 }); } } await dbContext.SaveChangesAsync(stoppingToken); _logger.LogInformation("Catálogo de Categorías Electorales sincronizado."); // 2. SINCRONIZAR AGRUPACIONES POLÍTICAS _logger.LogInformation("Iniciando sincronización de Agrupaciones Políticas..."); var agrupacionesEnDb = await dbContext.AgrupacionesPoliticas.ToDictionaryAsync(a => a.Id, a => a, stoppingToken); foreach (var categoria in distinctCategorias) { if (stoppingToken.IsCancellationRequested) break; // Se pasa 'null' como distritoId para obtener todas las agrupaciones de la categoría. var agrupacionesApi = await _apiService.GetAgrupacionesAsync(authToken, null, categoria.CategoriaId); if (agrupacionesApi != null) { 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); // Añadir al diccionario para evitar duplicados en el mismo ciclo } } } } int agrupacionesGuardadas = await dbContext.SaveChangesAsync(stoppingToken); _logger.LogInformation("Catálogo de Agrupaciones Políticas sincronizado. Se guardaron {count} nuevos registros.", agrupacionesGuardadas); // 3. SINCRONIZAR ÁMBITOS GEOGRÁFICOS _logger.LogInformation("Iniciando sincronización de Ámbitos Geográficos..."); var ambitosEnDbKeys = new HashSet( await dbContext.AmbitosGeograficos.Select(a => $"{a.NivelId}|{a.DistritoId}|{a.SeccionProvincialId}|{a.SeccionId}|{a.MunicipioId}|{a.CircuitoId}|{a.EstablecimientoId}|{a.MesaId}").ToListAsync(stoppingToken) ); int totalNuevosAmbitos = 0; foreach (var categoria in distinctCategorias) { if (stoppingToken.IsCancellationRequested) break; var catalogoDto = await _apiService.GetCatalogoAmbitosAsync(authToken, categoria.CategoriaId); if (catalogoDto != null) { 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 (ambitosEnDbKeys.Add(claveUnica)) // HashSet.Add devuelve true si el elemento no existía { string nombreCorregido = ambitoDto.Nombre; if (ambitoDto.CodigoAmbitos.DistritoId == "01" && ambitoDto.NivelId == 30 && int.TryParse(ambitoDto.Nombre, out int numeroComuna)) { nombreCorregido = $"COMUNA {numeroComuna}"; } dbContext.AmbitosGeograficos.Add(new AmbitoGeografico { Nombre = nombreCorregido, 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, }); } } } if (dbContext.ChangeTracker.HasChanges()) { int ambitosGuardados = await dbContext.SaveChangesAsync(stoppingToken); totalNuevosAmbitos += ambitosGuardados; _logger.LogInformation("Guardados {count} nuevos ámbitos para la categoría '{catNombre}'.", ambitosGuardados, categoria.Nombre); } } _logger.LogInformation("Catálogo de Ámbitos Geográficos sincronizado. Se guardaron {count} nuevos registros en total.", totalNuevosAmbitos); _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."); } } /// /// 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. /// /// El token de autenticación válido para la sesión. /// El token de cancelación para detener la operación. private async Task SondearProyeccionBancasAsync(string authToken, CancellationToken stoppingToken) { try { using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var categoriasDeBancas = await dbContext.CategoriasElectorales .AsNoTracking() .Where(c => c.Nombre.Contains("SENADORES") || c.Nombre.Contains("DIPUTADOS")) .ToListAsync(stoppingToken); // --- MODIFICACIÓN 1: Obtener todos los ámbitos en una sola consulta --- var ambitosASondear = await dbContext.AmbitosGeograficos .AsNoTracking() .Where(a => (a.NivelId == 10 || a.NivelId == 20) && a.DistritoId != null) .ToListAsync(stoppingToken); var provincia = ambitosASondear.FirstOrDefault(a => a.NivelId == 10); var seccionesElectorales = ambitosASondear.Where(a => a.NivelId == 20).ToList(); 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(); bool hasReceivedAnyNewData = false; // --- MODIFICACIÓN 2: Usar un diccionario para no buscar repetidamente en la BD --- var agrupacionesEnDb = await dbContext.AgrupacionesPoliticas.ToDictionaryAsync(a => a.Id, a => a, stoppingToken); // Bucle combinado para todos los ámbitos foreach (var ambito in ambitosASondear) { if (stoppingToken.IsCancellationRequested) break; foreach (var categoria in categoriasDeBancas) { if (stoppingToken.IsCancellationRequested) break; // Llamada a la API (lógica adaptada para ambos niveles) var repartoBancasDto = await _apiService.GetBancasAsync(authToken, ambito.DistritoId!, ambito.SeccionProvincialId, categoria.Id); if (repartoBancasDto?.RepartoBancas is { Count: > 0 } bancas) { hasReceivedAnyNewData = true; DateTime fechaTotalizacion; if (!DateTime.TryParse(repartoBancasDto.FechaTotalizacion, out var parsedDate)) { _logger.LogWarning("No se pudo parsear FechaTotalizacion ('{dateString}') para bancas. Usando la hora actual.", repartoBancasDto.FechaTotalizacion); fechaTotalizacion = DateTime.UtcNow; } else { fechaTotalizacion = parsedDate.ToUniversalTime(); } foreach (var banca in bancas) { // --- MODIFICACIÓN 3: Lógica de "Upsert" para Agrupaciones --- if (!agrupacionesEnDb.ContainsKey(banca.IdAgrupacion)) { _logger.LogWarning("Agrupación con ID {AgrupacionId} ('{Nombre}') no encontrada. Creándola desde los datos de bancas.", banca.IdAgrupacion, banca.NombreAgrupacion); var nuevaAgrupacion = new AgrupacionPolitica { Id = banca.IdAgrupacion, Nombre = banca.NombreAgrupacion, IdTelegrama = banca.IdAgrupacionTelegrama ?? string.Empty }; await dbContext.AgrupacionesPoliticas.AddAsync(nuevaAgrupacion, stoppingToken); agrupacionesEnDb.Add(nuevaAgrupacion.Id, nuevaAgrupacion); // Añadir al diccionario para no volver a crearla } todasLasProyecciones.Add(new ProyeccionBanca { EleccionId = EleccionId, AmbitoGeograficoId = ambito.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); // Si se crearon nuevas agrupaciones, se guardarán aquí primero. await dbContext.SaveChangesAsync(stoppingToken); // Luego, procedemos con las proyecciones. 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, 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."); } } /// /// 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. /// /// El token de autenticación válido para la sesión. /// El token de cancelación para detener la operación. 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(); // La obtención de partidos y categorías no cambia 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 }) { // Creamos el DbContext para la operación de guardado using var innerScope = _serviceProvider.CreateScope(); var innerDbContext = innerScope.ServiceProvider.GetRequiredService(); 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); var originalTimeout = innerDbContext.Database.GetCommandTimeout(); try { innerDbContext.Database.SetCommandTimeout(180); _logger.LogDebug("Timeout de BD aumentado a 180s para descarga de telegramas."); int contadorLote = 0; const int tamanoLote = 100; foreach (var mesaId in nuevosTelegramasIds) { if (stoppingToken.IsCancellationRequested) return; var telegramaFile = await _apiService.GetTelegramaFileAsync(authToken, mesaId); if (telegramaFile != null) { var ambitoMesa = await innerDbContext.AmbitosGeograficos.AsNoTracking() .FirstOrDefaultAsync(a => a.MesaId == mesaId, stoppingToken); if (ambitoMesa != null) { var nuevoTelegrama = new Telegrama { EleccionId = EleccionId, Id = telegramaFile.NombreArchivo, AmbitoGeograficoId = ambitoMesa.Id, ContenidoBase64 = telegramaFile.Imagen, FechaEscaneo = DateTime.Parse(telegramaFile.FechaEscaneo).ToUniversalTime(), FechaTotalizacion = DateTime.Parse(telegramaFile.FechaTotalizacion).ToUniversalTime() }; await innerDbContext.Telegramas.AddAsync(nuevoTelegrama, stoppingToken); contadorLote++; } 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); if (contadorLote >= tamanoLote) { await innerDbContext.SaveChangesAsync(stoppingToken); _logger.LogInformation("Guardado un lote de {count} telegramas.", contadorLote); contadorLote = 0; } } if (contadorLote > 0) { await innerDbContext.SaveChangesAsync(stoppingToken); _logger.LogInformation("Guardado el último lote de {count} telegramas.", contadorLote); } } finally { innerDbContext.Database.SetCommandTimeout(originalTimeout); _logger.LogDebug("Timeout de BD restaurado a su valor original ({timeout}s).", originalTimeout); } } // Fin del if (nuevosTelegramasIds.Any()) // Movemos el delay aquí para que solo se ejecute si hubo telegramas en la respuesta de la API await Task.Delay(100, stoppingToken); } // Fin del if (listaTelegramasApi is not null) } } _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."); } } }