//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 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 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 { _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(); // --- 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."); } } /// /// 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..."); // --- CORRECCIÓN: Usar el _tokenService inyectado --- var authToken = await _tokenService.GetValidAuthTokenAsync(stoppingToken); if (string.IsNullOrEmpty(authToken) || stoppingToken.IsCancellationRequested) { _logger.LogError("No se pudo obtener token para la sincronización de catálogos. La operación se cancela."); return; } // Creamos un scope de servicios para obtener una instancia fresca de DbContext. using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); // PASO 2: Sincronizar las categorías electorales. // Es un catálogo pequeño y es la base para las siguientes consultas. var categoriasApi = await _apiService.GetCategoriasAsync(authToken); if (categoriasApi is null || !categoriasApi.Any()) { _logger.LogWarning("La API no devolvió datos para el catálogo de Categorías. La sincronización no puede continuar."); return; } var distinctCategorias = categoriasApi.GroupBy(c => c.CategoriaId).Select(g => g.First()).OrderBy(c => c.Orden).ToList(); _logger.LogInformation("Se procesarán {count} categorías electorales.", distinctCategorias.Count); var categoriasEnDb = await dbContext.CategoriasElectorales.ToDictionaryAsync(c => c.Id, c => c, stoppingToken); foreach (var categoriaDto in distinctCategorias) { if (!categoriasEnDb.ContainsKey(categoriaDto.CategoriaId)) { dbContext.CategoriasElectorales.Add(new CategoriaElectoral { Id = categoriaDto.CategoriaId, Nombre = categoriaDto.Nombre, Orden = categoriaDto.Orden }); } } // Guardamos las categorías primero para asegurar su existencia. await dbContext.SaveChangesAsync(stoppingToken); // PASO 3: Cargar los catálogos existentes en memoria para una comparación eficiente. // Esto evita hacer miles de consultas a la BD dentro de un bucle. // Para los ámbitos, creamos una clave única robusta que funciona incluso con campos nulos. var ambitosEnDb = new Dictionary(); var todosLosAmbitos = await dbContext.AmbitosGeograficos.ToListAsync(stoppingToken); foreach (var ambito in todosLosAmbitos) { string clave = $"{ambito.NivelId}|{ambito.DistritoId}|{ambito.SeccionProvincialId}|{ambito.SeccionId}|{ambito.MunicipioId}|{ambito.CircuitoId}|{ambito.EstablecimientoId}|{ambito.MesaId}"; if (!ambitosEnDb.ContainsKey(clave)) { ambitosEnDb.Add(clave, ambito); } } var agrupacionesEnDb = await dbContext.AgrupacionesPoliticas.ToDictionaryAsync(a => a.Id, a => a, stoppingToken); // Variable para llevar la cuenta del total de registros insertados. int totalCambiosGuardados = 0; // PASO 4: Iterar sobre cada categoría para sincronizar sus ámbitos y agrupaciones. foreach (var categoria in distinctCategorias) { if (stoppingToken.IsCancellationRequested) break; _logger.LogInformation("--- Sincronizando datos para la categoría: {Nombre} (ID: {Id}) ---", categoria.Nombre, categoria.CategoriaId); var catalogoDto = await _apiService.GetCatalogoAmbitosAsync(authToken, categoria.CategoriaId); if (catalogoDto != null) { // 4.1 - Procesar y añadir ÁMBITOS nuevos al DbContext foreach (var ambitoDto in catalogoDto.Ambitos) { string claveUnica = $"{ambitoDto.NivelId}|{ambitoDto.CodigoAmbitos.DistritoId}|{ambitoDto.CodigoAmbitos.SeccionProvincialId}|{ambitoDto.CodigoAmbitos.SeccionId}|{ambitoDto.CodigoAmbitos.MunicipioId}|{ambitoDto.CodigoAmbitos.CircuitoId}|{ambitoDto.CodigoAmbitos.EstablecimientoId}|{ambitoDto.CodigoAmbitos.MesaId}"; if (!ambitosEnDb.ContainsKey(claveUnica)) { var nuevoAmbito = new AmbitoGeografico { Nombre = ambitoDto.Nombre, NivelId = ambitoDto.NivelId, DistritoId = ambitoDto.CodigoAmbitos.DistritoId, SeccionProvincialId = ambitoDto.CodigoAmbitos.SeccionProvincialId, SeccionId = ambitoDto.CodigoAmbitos.SeccionId, MunicipioId = ambitoDto.CodigoAmbitos.MunicipioId, CircuitoId = ambitoDto.CodigoAmbitos.CircuitoId, EstablecimientoId = ambitoDto.CodigoAmbitos.EstablecimientoId, MesaId = ambitoDto.CodigoAmbitos.MesaId, }; dbContext.AmbitosGeograficos.Add(nuevoAmbito); ambitosEnDb.Add(claveUnica, nuevoAmbito); // Añadir también al diccionario en memoria } } // 4.2 - Procesar y añadir AGRUPACIONES nuevas al DbContext var provincia = catalogoDto.Ambitos.FirstOrDefault(a => a.NivelId == 10); if (provincia != null && !string.IsNullOrEmpty(provincia.CodigoAmbitos.DistritoId)) { // Usamos un try-catch porque no todas las categorías tienen agrupaciones a nivel provincial. try { var agrupacionesApi = await _apiService.GetAgrupacionesAsync(authToken, provincia.CodigoAmbitos.DistritoId, categoria.CategoriaId); if (agrupacionesApi != null && agrupacionesApi.Any()) { foreach (var agrupacionDto in agrupacionesApi) { if (!agrupacionesEnDb.ContainsKey(agrupacionDto.IdAgrupacion)) { var nuevaAgrupacion = new AgrupacionPolitica { Id = agrupacionDto.IdAgrupacion, IdTelegrama = agrupacionDto.IdAgrupacionTelegrama, Nombre = agrupacionDto.NombreAgrupacion }; dbContext.AgrupacionesPoliticas.Add(nuevaAgrupacion); agrupacionesEnDb.Add(nuevaAgrupacion.Id, nuevaAgrupacion); } } } } catch (Exception ex) { _logger.LogWarning(ex, "No se pudieron obtener agrupaciones para la categoría '{catNombre}' ({catId}).", categoria.Nombre, categoria.CategoriaId); } } } // Después de procesar todos los ámbitos y agrupaciones de UNA categoría, guardamos los cambios. // Esto divide la inserción masiva de ~50,000 registros en 3 transacciones más pequeñas, // evitando timeouts y fallos en la base de datos. if (dbContext.ChangeTracker.HasChanges()) { int cambiosEnLote = await dbContext.SaveChangesAsync(stoppingToken); totalCambiosGuardados += cambiosEnLote; _logger.LogInformation("Guardados {count} registros de catálogo para la categoría '{catNombre}'.", cambiosEnLote, categoria.Nombre); } } // Ya no hay un SaveChangesAsync() gigante aquí. _logger.LogInformation("{count} nuevos registros de catálogo han sido guardados en total.", totalCambiosGuardados); _logger.LogInformation("Sincronización de catálogos maestros finalizada."); } catch (Exception ex) { _logger.LogError(ex, "Ocurrió un error CRÍTICO durante la sincronización de catálogos."); } } /// /// 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 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."); } } }