diff --git a/Elecciones-Web/src/Elecciones.Infrastructure/Services/ElectoralApiService.cs b/Elecciones-Web/src/Elecciones.Infrastructure/Services/ElectoralApiService.cs index 43e8f76..582c3c4 100644 --- a/Elecciones-Web/src/Elecciones.Infrastructure/Services/ElectoralApiService.cs +++ b/Elecciones-Web/src/Elecciones.Infrastructure/Services/ElectoralApiService.cs @@ -89,12 +89,11 @@ public class ElectoralApiService : IElectoralApiService return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync() : null; } - public async Task?> GetTelegramasTotalizadosAsync(string authToken, string distritoId, string seccionId, int? categoriaId = null) + public async Task?> GetTelegramasTotalizadosAsync(string authToken, string distritoId, string seccionId, int? categoriaId = null) { var client = _httpClientFactory.CreateClient("ElectoralApiClient"); var requestUri = $"/api/resultados/getTelegramasTotalizados?distritoId={distritoId}&seccionId={seccionId}"; - // Añadimos el parámetro categoriaId a la URL SÓLO si se proporciona un valor. if (categoriaId.HasValue) { requestUri += $"&categoriaId={categoriaId.Value}"; @@ -103,8 +102,9 @@ public class ElectoralApiService : IElectoralApiService var request = new HttpRequestMessage(HttpMethod.Get, requestUri); request.Headers.Add("Authorization", $"Bearer {authToken}"); var response = await client.SendAsync(request); - // Si la respuesta es 400, devolvemos null para que el worker sepa que falló. - return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync>() : null; + + // Ahora deserializamos al tipo correcto: List + return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync>() : null; } public async Task GetTelegramaFileAsync(string authToken, string mesaId) diff --git a/Elecciones-Web/src/Elecciones.Infrastructure/Services/IElectoralApiService.cs b/Elecciones-Web/src/Elecciones.Infrastructure/Services/IElectoralApiService.cs index a49b60b..4fdb033 100644 --- a/Elecciones-Web/src/Elecciones.Infrastructure/Services/IElectoralApiService.cs +++ b/Elecciones-Web/src/Elecciones.Infrastructure/Services/IElectoralApiService.cs @@ -14,7 +14,7 @@ public interface IElectoralApiService Task?> GetAgrupacionesAsync(string authToken, string distritoId, int categoriaId); Task GetResultadosAsync(string authToken, string distritoId, string seccionId, string? municipioId, int categoriaId); Task GetBancasAsync(string authToken, string distritoId, string? seccionProvincialId, int categoriaId); - Task?> GetTelegramasTotalizadosAsync(string authToken, string distritoId, string seccionId, int? categoriaId = null); + Task?> GetTelegramasTotalizadosAsync(string authToken, string distritoId, string seccionId, int? categoriaId = null); Task GetTelegramaFileAsync(string authToken, string mesaId); Task GetResumenAsync(string authToken, string distritoId); Task GetEstadoRecuentoGeneralAsync(string authToken, string distritoId, int categoriaId); diff --git a/Elecciones-Web/src/Elecciones.Worker/Worker.cs b/Elecciones-Web/src/Elecciones.Worker/Worker.cs index 27dce99..db4ddb3 100644 --- a/Elecciones-Web/src/Elecciones.Worker/Worker.cs +++ b/Elecciones-Web/src/Elecciones.Worker/Worker.cs @@ -2,6 +2,7 @@ using Elecciones.Database; using Elecciones.Database.Entities; using Elecciones.Infrastructure.Services; using Microsoft.EntityFrameworkCore; +using System.Collections.Concurrent; using Microsoft.Extensions.Logging; using System; using System.Linq; @@ -56,18 +57,18 @@ public class Worker : BackgroundService _logger.LogInformation("--- Iniciando sondeo de Resultados Municipales ---"); await SondearResultadosMunicipalesAsync(authToken, stoppingToken); - _logger.LogInformation("--- Iniciando sondeo de Proyección de Bancas ---"); - await SondearProyeccionBancasAsync(authToken, stoppingToken); - - _logger.LogInformation("--- Iniciando sondeo de Nuevos Telegramas ---"); - await SondearNuevosTelegramasAsync(authToken, stoppingToken); - _logger.LogInformation("--- Iniciando sondeo de Resumen Provincial ---"); await SondearResumenProvincialAsync(authToken, stoppingToken); _logger.LogInformation("--- Iniciando sondeo de Estado de Recuento General ---"); await SondearEstadoRecuentoGeneralAsync(authToken, stoppingToken); + _logger.LogInformation("--- Iniciando sondeo de Proyección de Bancas ---"); + await SondearProyeccionBancasAsync(authToken, stoppingToken); + + _logger.LogInformation("--- Iniciando sondeo de Nuevos Telegramas ---"); + await SondearNuevosTelegramasAsync(authToken, stoppingToken); + try { _logger.LogInformation("Ciclo de sondeo completado. Esperando 5 minutos para el siguiente..."); @@ -83,174 +84,179 @@ public class Worker : BackgroundService } /// -/// 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 + /// 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) { - _logger.LogInformation("Iniciando sincronización de catálogos maestros..."); - - // PASO 1: Obtener el token de autenticación. Sin él, no podemos hacer nada. - var authToken = await _apiService.GetAuthTokenAsync(); - if (string.IsNullOrEmpty(authToken) || stoppingToken.IsCancellationRequested) + try { - _logger.LogError("No se pudo obtener token para la sincronización de catálogos. La operación se cancela."); - return; - } + _logger.LogInformation("Iniciando sincronización de catálogos maestros..."); - // 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)) + // PASO 1: Obtener el token de autenticación. Sin él, no podemos hacer nada. + var authToken = await _apiService.GetAuthTokenAsync(); + if (string.IsNullOrEmpty(authToken) || stoppingToken.IsCancellationRequested) { - dbContext.CategoriasElectorales.Add(new CategoriaElectoral { Id = categoriaDto.CategoriaId, Nombre = categoriaDto.Nombre, Orden = categoriaDto.Orden }); + _logger.LogError("No se pudo obtener token para la sincronización de catálogos. La operación se cancela."); + return; } - } - // 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)) + // 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()) { - ambitosEnDb.Add(clave, ambito); + _logger.LogWarning("La API no devolvió datos para el catálogo de Categorías. La sincronización no puede continuar."); + return; } - } - 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; + 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); - // 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) + var categoriasEnDb = await dbContext.CategoriasElectorales.ToDictionaryAsync(c => c.Id, c => c, stoppingToken); + foreach (var categoriaDto in distinctCategorias) { - // 4.1 - Procesar y añadir ÁMBITOS nuevos al DbContext - foreach (var ambitoDto in catalogoDto.Ambitos) + if (!categoriasEnDb.ContainsKey(categoriaDto.CategoriaId)) { - 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 - } + 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); - // 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)) + // 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)) { - // Usamos un try-catch porque no todas las categorías tienen agrupaciones a nivel provincial. - try + 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) { - var agrupacionesApi = await _apiService.GetAgrupacionesAsync(authToken, provincia.CodigoAmbitos.DistritoId, categoria.CategoriaId); - if (agrupacionesApi != null && agrupacionesApi.Any()) + 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)) { - foreach (var agrupacionDto in agrupacionesApi) + var nuevoAmbito = new AmbitoGeografico { - if (!agrupacionesEnDb.ContainsKey(agrupacionDto.IdAgrupacion)) + 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) { - var nuevaAgrupacion = new AgrupacionPolitica + if (!agrupacionesEnDb.ContainsKey(agrupacionDto.IdAgrupacion)) { - Id = agrupacionDto.IdAgrupacion, - IdTelegrama = agrupacionDto.IdAgrupacionTelegrama, - Nombre = agrupacionDto.NombreAgrupacion - }; - dbContext.AgrupacionesPoliticas.Add(nuevaAgrupacion); - agrupacionesEnDb.Add(nuevaAgrupacion.Id, nuevaAgrupacion); + 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); + } } - 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); } } - // 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."); } - - // 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."); - } -} - // El resto de los métodos (SondearResultadosMunicipalesAsync, GuardarResultadosDeAmbitoAsync, etc.) - // se mantienen como en la versión anterior que te proporcioné. Los incluyo aquí para - // que tengas el archivo completo y sin errores. + /// + /// Sondea los resultados electorales para todos los municipios/partidos de forma optimizada. + /// Utiliza paralelismo controlado para ejecutar múltiples peticiones a la API simultáneamente + /// sin sobrecargar la red, y luego guarda todos los resultados en la base de datos de forma masiva. + /// + /// El token de autenticación válido para la sesión. + /// El token de cancelación para detener la operación. private async Task SondearResultadosMunicipalesAsync(string authToken, CancellationToken stoppingToken) { try { + // PASO 1: Preparar el DbContext y los datos necesarios. using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); + // Obtenemos de nuestra BD local la lista de todos los partidos (NivelId=30) que necesitamos consultar. var municipiosASondear = await dbContext.AmbitosGeograficos .AsNoTracking() .Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null) - // El MunicipioId es opcional en la BD, lo quitamos del Where para asegurar que traiga todos los partidos .Select(a => new { a.Id, a.Nombre, a.MunicipioId, a.SeccionId, a.DistritoId }) .ToListAsync(stoppingToken); @@ -259,8 +265,8 @@ private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingT _logger.LogWarning("No se encontraron Partidos (NivelId 30) en la BD para sondear resultados."); return; } - _logger.LogInformation("Iniciando sondeo de resultados para {count} municipios (Partidos)...", municipiosASondear.Count); + // Obtenemos la categoría "CONCEJALES", ya que los resultados municipales aplican a esta. var categoriaConcejales = await dbContext.CategoriasElectorales .AsNoTracking() .FirstOrDefaultAsync(c => c.Nombre.Contains("CONCEJALES"), stoppingToken); @@ -271,29 +277,64 @@ private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingT return; } - var todosLosResultados = new Dictionary(); - foreach (var municipio in municipiosASondear) + // PASO 2: Ejecutar las consultas a la API con paralelismo controlado. + + // Definimos cuántas peticiones queremos que se ejecuten simultáneamente. + // Un valor entre 8 y 16 es generalmente seguro y ofrece una gran mejora de velocidad. + const int GRADO_DE_PARALELISMO = 10; + // Creamos un semáforo que actuará como un "control de acceso" con 10 pases libres. + var semaforo = new SemaphoreSlim(GRADO_DE_PARALELISMO); + + // Usamos un ConcurrentDictionary para almacenar los resultados. A diferencia de un Dictionary normal, + // este permite que múltiples tareas escriban en él al mismo tiempo sin conflictos. + var resultadosPorId = new ConcurrentDictionary(); + + _logger.LogInformation("Iniciando sondeo de resultados para {count} municipios con un paralelismo de {degree}...", municipiosASondear.Count, GRADO_DE_PARALELISMO); + + // Creamos una lista de tareas (Tasks), una por cada municipio a consultar. + // El método .Select() no ejecuta las tareas todavía, solo las prepara. + var tareas = municipiosASondear.Select(async municipio => { - if (stoppingToken.IsCancellationRequested) break; - - var resultados = await _apiService.GetResultadosAsync( - authToken, municipio.DistritoId!, municipio.SeccionId!, null, categoriaConcejales.Id - ); - - if (resultados != null) + // Cada tarea debe "pedir permiso" al semáforo antes de ejecutarse. + // Si ya hay 10 tareas en ejecución, esta línea esperará hasta que una termine. + await semaforo.WaitAsync(stoppingToken); + try { - todosLosResultados[municipio.Id] = resultados; - } - } + // Una vez que obtiene el permiso, ejecuta la petición a la API. + var resultados = await _apiService.GetResultadosAsync( + authToken, municipio.DistritoId!, municipio.SeccionId!, null, categoriaConcejales.Id + ); - if (todosLosResultados.Any()) + // Si la API devuelve datos válidos... + if (resultados != null) + { + // ...los guardamos en el diccionario concurrente. + resultadosPorId[municipio.Id] = resultados; + } + } + finally + { + // ¡CRUCIAL! Liberamos el pase del semáforo, permitiendo que la siguiente + // tarea en espera pueda comenzar su ejecución. + semaforo.Release(); + } + }); + + // Ahora sí, ejecutamos todas las tareas preparadas en paralelo y esperamos a que todas terminen. + await Task.WhenAll(tareas); + + // PASO 3: Guardar los resultados en la base de datos. + // Solo procedemos si recolectamos al menos un resultado válido. + if (resultadosPorId.Any()) { - // La llamada ahora es correcta porque el método receptor espera 3 argumentos - await GuardarResultadosDeMunicipiosAsync(dbContext, todosLosResultados, stoppingToken); + // Llamamos a nuestro método de guardado masivo y optimizado, pasándole todos los resultados + // recolectados para que los inserte en una única y eficiente transacción. + await GuardarResultadosDeMunicipiosAsync(dbContext, resultadosPorId.ToDictionary(kv => kv.Key, kv => kv.Value), stoppingToken); } } catch (Exception ex) { + // Capturamos cualquier error inesperado en el proceso para que el worker no se detenga. _logger.LogError(ex, "Ocurrió un error inesperado durante el sondeo de resultados municipales."); } } @@ -498,10 +539,10 @@ private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingT } /// - /// Busca en la API si hay nuevos telegramas totalizados que no se encuentren en la base de datos local. - /// Este método itera sobre cada Partido/Municipio (que la API identifica como "Sección" con NivelId = 30), - /// obtiene la lista de IDs de telegramas para cada uno, los compara con los IDs locales, - /// y finalmente descarga y guarda solo los que son nuevos. + /// 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. @@ -509,16 +550,17 @@ private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingT { try { - using var scope = _serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); + // PASO 1: Obtener los datos base para las consultas. + // Usamos un DbContext inicial solo para leer los catálogos. + using var initialScope = _serviceProvider.CreateScope(); + var initialDbContext = initialScope.ServiceProvider.GetRequiredService(); - // Obtenemos todos los partidos (NivelId=30) y todas las categorías para iterar - var partidos = await dbContext.AmbitosGeograficos + var partidos = await initialDbContext.AmbitosGeograficos .AsNoTracking() .Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null) .ToListAsync(stoppingToken); - var categorias = await dbContext.CategoriasElectorales + var categorias = await initialDbContext.CategoriasElectorales .AsNoTracking() .ToListAsync(stoppingToken); @@ -528,26 +570,29 @@ private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingT return; } - _logger.LogInformation("Iniciando sondeo de Telegramas nuevos para {partidosCount} partidos y {categoriasCount} categorías...", partidos.Count, categorias.Count); + // Creamos una lista de todas las consultas que necesitamos hacer (135 partidos * 3 categorías = 405 consultas). + var combinaciones = partidos.SelectMany(partido => categorias, (partido, categoria) => new { partido, categoria }); - foreach (var partido in partidos) + const int GRADO_DE_PARALELISMO = 10; + var semaforo = new SemaphoreSlim(GRADO_DE_PARALELISMO); + + _logger.LogInformation("Iniciando sondeo de Telegramas para {count} combinaciones... con paralelismo de {degree}", combinaciones.Count(), GRADO_DE_PARALELISMO); + + var tareas = combinaciones.Select(async item => { - if (stoppingToken.IsCancellationRequested) break; - - foreach (var categoria in categorias) + await semaforo.WaitAsync(stoppingToken); + try { - if (stoppingToken.IsCancellationRequested) break; + var idsDeApi = await _apiService.GetTelegramasTotalizadosAsync(authToken, item.partido.DistritoId!, item.partido.SeccionId!, item.categoria.Id); - // Llamamos al servicio pasando la categoriaId. - // Si mañana la API ya no lo necesita, simplemente lo ignorará. - // Si lo vuelve a necesitar en el futuro, nuestro código ya está preparado. - var listaTelegramasApi = await _apiService.GetTelegramasTotalizadosAsync(authToken, partido.DistritoId!, partido.SeccionId!, categoria.Id); - - // El resto de la lógica es la misma: si la respuesta es válida y contiene datos... - if (listaTelegramasApi is { Count: > 0 }) + if (idsDeApi is { Count: > 0 }) { - var idsDeApi = listaTelegramasApi.Select(t => t[0]).Distinct().ToList(); - var idsYaEnDb = await dbContext.Telegramas + using var innerScope = _serviceProvider.CreateScope(); + var innerDbContext = innerScope.ServiceProvider.GetRequiredService(); + + // --- CORRECCIÓN CLAVE --- + // 'idsDeApi' ya es una List, no necesitamos hacer .Select(t => t[0]) + var idsYaEnDb = await innerDbContext.Telegramas .Where(t => idsDeApi.Contains(t.Id)) .Select(t => t.Id) .ToListAsync(stoppingToken); @@ -556,11 +601,12 @@ private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingT if (!nuevosTelegramasIds.Any()) { - continue; + return; } - _logger.LogInformation("Se encontraron {count} telegramas nuevos en el partido '{nombre}' para la categoría '{cat}'. Descargando...", nuevosTelegramasIds.Count, partido.Nombre, categoria.Nombre); + _logger.LogInformation("Se encontraron {count} telegramas nuevos en '{partido}' para '{cat}'. Descargando...", nuevosTelegramasIds.Count, item.partido.Nombre, item.categoria.Nombre); + // Iteramos y descargamos cada nuevo telegrama. foreach (var mesaId in nuevosTelegramasIds) { if (stoppingToken.IsCancellationRequested) break; @@ -570,18 +616,28 @@ private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingT var nuevoTelegrama = new Telegrama { Id = telegramaFile.NombreArchivo, - AmbitoGeograficoId = partido.Id, + AmbitoGeograficoId = item.partido.Id, ContenidoBase64 = telegramaFile.Imagen, FechaEscaneo = DateTime.Parse(telegramaFile.FechaEscaneo).ToUniversalTime(), FechaTotalizacion = DateTime.Parse(telegramaFile.FechaTotalizacion).ToUniversalTime() }; - await dbContext.Telegramas.AddAsync(nuevoTelegrama, stoppingToken); + await innerDbContext.Telegramas.AddAsync(nuevoTelegrama, stoppingToken); } } - await dbContext.SaveChangesAsync(stoppingToken); + // Guardamos los cambios de ESTA tarea específica en la BD. + await innerDbContext.SaveChangesAsync(stoppingToken); } } - } + finally + { + // Liberamos el semáforo para que otra tarea pueda comenzar. + semaforo.Release(); + } + }); + + // Ejecutamos todas las tareas en paralelo y esperamos a que finalicen. + await Task.WhenAll(tareas); + _logger.LogInformation("Sondeo de Telegramas completado."); } catch (Exception ex)