//Elecciones.Worker/CriticalDataWorker.cs 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 _logger; private readonly SharedTokenService _tokenService; private readonly IServiceProvider _serviceProvider; private readonly IElectoralApiService _apiService; private readonly WorkerConfigService _configService; public CriticalDataWorker( 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 Datos Críticos iniciado."); try { await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); } catch (TaskCanceledException) { return; } 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; } 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; _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; } } } /// /// 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); 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(); 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."); } } /// /// 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 { 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."); } } 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(); // --- 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; 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) { 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); } } /// /// 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 { 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 { // 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(); // 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."); } } }