From b7c50576f4048551f361e70099157a4b51203f4a Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 19 Aug 2025 09:37:13 -0300 Subject: [PATCH] Fix Cambios de optimizaciones --- .../Services/ElectoralApiService.cs | 12 +- .../Services/IElectoralApiService.cs | 2 +- .../src/Elecciones.Worker/Worker.cs | 513 ++++++++++-------- 3 files changed, 288 insertions(+), 239 deletions(-) diff --git a/Elecciones-Web/src/Elecciones.Infrastructure/Services/ElectoralApiService.cs b/Elecciones-Web/src/Elecciones.Infrastructure/Services/ElectoralApiService.cs index 9d126b1..43e8f76 100644 --- a/Elecciones-Web/src/Elecciones.Infrastructure/Services/ElectoralApiService.cs +++ b/Elecciones-Web/src/Elecciones.Infrastructure/Services/ElectoralApiService.cs @@ -72,11 +72,17 @@ public class ElectoralApiService : IElectoralApiService return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync() : null; } - public async Task GetBancasAsync(string authToken, string distritoId, string seccionId, int categoriaId) + public async Task GetBancasAsync(string authToken, string distritoId, string? seccionProvincialId, int categoriaId) { var client = _httpClientFactory.CreateClient("ElectoralApiClient"); - // Usamos la categoriaId recibida en lugar de una fija - var requestUri = $"/api/resultados/getBancas?distritoId={distritoId}&seccionId={seccionId}&categoriaId={categoriaId}"; + var requestUri = $"/api/resultados/getBancas?distritoId={distritoId}&categoriaId={categoriaId}"; + + // Añadimos el seccionProvincialId a la URL SÓLO si tiene un valor. + if (!string.IsNullOrEmpty(seccionProvincialId)) + { + requestUri += $"&seccionProvincialId={seccionProvincialId}"; + } + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); request.Headers.Add("Authorization", $"Bearer {authToken}"); var response = await client.SendAsync(request); diff --git a/Elecciones-Web/src/Elecciones.Infrastructure/Services/IElectoralApiService.cs b/Elecciones-Web/src/Elecciones.Infrastructure/Services/IElectoralApiService.cs index f0c3faf..a49b60b 100644 --- a/Elecciones-Web/src/Elecciones.Infrastructure/Services/IElectoralApiService.cs +++ b/Elecciones-Web/src/Elecciones.Infrastructure/Services/IElectoralApiService.cs @@ -13,7 +13,7 @@ public interface IElectoralApiService Task GetCatalogoAmbitosAsync(string authToken, int categoriaId); 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 seccionId, int categoriaId); + Task GetBancasAsync(string authToken, string distritoId, string? seccionProvincialId, int categoriaId); Task?> GetTelegramasTotalizadosAsync(string authToken, string distritoId, string seccionId, int? categoriaId = null); Task GetTelegramaFileAsync(string authToken, string mesaId); Task GetResumenAsync(string authToken, string distritoId); diff --git a/Elecciones-Web/src/Elecciones.Worker/Worker.cs b/Elecciones-Web/src/Elecciones.Worker/Worker.cs index 483bc80..27dce99 100644 --- a/Elecciones-Web/src/Elecciones.Worker/Worker.cs +++ b/Elecciones-Web/src/Elecciones.Worker/Worker.cs @@ -83,137 +83,159 @@ public class Worker : BackgroundService } /// - /// Descarga y sincroniza los catálogos base (Categorías, Ámbitos, Agrupaciones) - /// desde la API a la base de datos local. - /// - private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingToken) +/// 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 { - try + _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) { - _logger.LogInformation("Iniciando sincronización de catálogos maestros..."); - var authToken = await _apiService.GetAuthTokenAsync(); - 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)) { - _logger.LogError("No se pudo obtener token para la sincronización de catálogos."); - return; + 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); - using var scope = _serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - // --- 1. SINCRONIZAR CATEGORÍAS ELECTORALES --- - var categoriasApi = await _apiService.GetCategoriasAsync(authToken); - if (categoriasApi is null || !categoriasApi.Any()) + // 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)) { - _logger.LogWarning("No se recibieron datos del catálogo de Categorías."); - return; + ambitosEnDb.Add(clave, ambito); } + } - 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 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 categoriasEnDb = await dbContext.CategoriasElectorales.ToDictionaryAsync(c => c.Id, c => c, stoppingToken); - foreach (var categoriaDto in distinctCategorias) + // 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) { - if (!categoriasEnDb.ContainsKey(categoriaDto.CategoriaId)) + // 4.1 - Procesar y añadir ÁMBITOS nuevos al DbContext + foreach (var ambitoDto in catalogoDto.Ambitos) { - dbContext.CategoriasElectorales.Add(new CategoriaElectoral { Id = categoriaDto.CategoriaId, Nombre = categoriaDto.Nombre, Orden = categoriaDto.Orden }); - } - } - await dbContext.SaveChangesAsync(stoppingToken); + 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}"; - // --- 2. SINCRONIZAR ÁMBITOS Y AGRUPACIONES POR CADA CATEGORÍA --- - - var ambitosEnDb = new Dictionary(); - var todosLosAmbitos = await dbContext.AmbitosGeograficos.ToListAsync(stoppingToken); - - foreach (var ambito in todosLosAmbitos) - { - // Creamos una clave única que SIEMPRE funciona, incluso con nulos. - 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); - - 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) - { - foreach (var ambitoDto in catalogoDto.Ambitos) + if (!ambitosEnDb.ContainsKey(claveUnica)) { - // Volvemos a generar la misma clave única para la comparación - 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 { - 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); - } + 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 } + } - // Lógica para sincronizar AGRUPACIONES POLÍTICAS - var provincia = catalogoDto.Ambitos.FirstOrDefault(a => a.NivelId == 10); - if (provincia != null && !string.IsNullOrEmpty(provincia.CodigoAmbitos.DistritoId)) + // 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 { - try + var agrupacionesApi = await _apiService.GetAgrupacionesAsync(authToken, provincia.CodigoAmbitos.DistritoId, categoria.CategoriaId); + if (agrupacionesApi != null && agrupacionesApi.Any()) { - var agrupacionesApi = await _apiService.GetAgrupacionesAsync(authToken, provincia.CodigoAmbitos.DistritoId, categoria.CategoriaId); - if (agrupacionesApi != null && agrupacionesApi.Any()) + foreach (var agrupacionDto in agrupacionesApi) { - foreach (var agrupacionDto in agrupacionesApi) + if (!agrupacionesEnDb.ContainsKey(agrupacionDto.IdAgrupacion)) { - if (!agrupacionesEnDb.ContainsKey(agrupacionDto.IdAgrupacion)) + var nuevaAgrupacion = new AgrupacionPolitica { - var nuevaAgrupacion = new AgrupacionPolitica - { - Id = agrupacionDto.IdAgrupacion, - IdTelegrama = agrupacionDto.IdAgrupacionTelegrama, - Nombre = agrupacionDto.NombreAgrupacion - }; - dbContext.AgrupacionesPoliticas.Add(nuevaAgrupacion); - agrupacionesEnDb.Add(nuevaAgrupacion.Id, nuevaAgrupacion); - } + 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}). Esto puede ser normal si la categoría no aplica a nivel provincial.", 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); } } } - // --- 3. GUARDADO FINAL --- - int cambiosGuardados = await dbContext.SaveChangesAsync(stoppingToken); - _logger.LogInformation("{count} nuevos registros de catálogo han sido guardados en la base de datos.", cambiosGuardados); - _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."); + // 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."); + } +} // El resto de los métodos (SondearResultadosMunicipalesAsync, GuardarResultadosDeAmbitoAsync, etc.) // se mantienen como en la versión anterior que te proporcioné. Los incluyo aquí para @@ -225,57 +247,50 @@ public class Worker : BackgroundService using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - // Cambiamos la búsqueda a NivelId = 30, que según la API - // son los registros de "Sección" (Partidos/Municipios). var municipiosASondear = await dbContext.AmbitosGeograficos - .AsNoTracking() - .Where(a => a.NivelId == 30 && a.MunicipioId != null && a.DistritoId != null && a.SeccionId != null) - .Select(a => new { a.Id, a.Nombre, a.MunicipioId, a.SeccionId, a.DistritoId }) - .ToListAsync(stoppingToken); + .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); if (!municipiosASondear.Any()) { - // Este log ahora mostrará 'NivelId 30' si falla. _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); + var categoriaConcejales = await dbContext.CategoriasElectorales + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Nombre.Contains("CONCEJALES"), stoppingToken); + + if (categoriaConcejales == null) + { + _logger.LogWarning("No se encontró la categoría 'CONCEJALES'. Omitiendo sondeo de resultados municipales."); + return; + } + + var todosLosResultados = new Dictionary(); foreach (var municipio in municipiosASondear) { if (stoppingToken.IsCancellationRequested) break; - var categoriaConcejales = await dbContext.CategoriasElectorales - .AsNoTracking() - .FirstOrDefaultAsync(c => c.Nombre.Contains("CONCEJALES"), stoppingToken); + var resultados = await _apiService.GetResultadosAsync( + authToken, municipio.DistritoId!, municipio.SeccionId!, null, categoriaConcejales.Id + ); - if (categoriaConcejales != null) + if (resultados != null) { - var resultados = await _apiService.GetResultadosAsync( - authToken, - municipio.DistritoId!, - municipio.SeccionId!, - null, - categoriaConcejales.Id - ); - - if (resultados != null) - { - try - { - await GuardarResultadosDeAmbitoAsync(dbContext, municipio.Id, resultados, stoppingToken); - // Ahora 'municipio.Nombre' existe y el log funcionará - _logger.LogInformation("Resultados para el municipio '{nombre}' (ID: {id}) guardados/actualizados.", municipio.Nombre, municipio.MunicipioId); - } - catch (Exception ex) - { - // Y aquí también funcionará, dándonos un error mucho más útil - _logger.LogError(ex, "FALLO CRÍTICO al guardar resultados para el municipio '{nombre}' (ID: {id}).", municipio.Nombre, municipio.MunicipioId); - } - } + todosLosResultados[municipio.Id] = resultados; } } + + if (todosLosResultados.Any()) + { + // La llamada ahora es correcta porque el método receptor espera 3 argumentos + await GuardarResultadosDeMunicipiosAsync(dbContext, todosLosResultados, stoppingToken); + } } catch (Exception ex) { @@ -283,173 +298,201 @@ public class Worker : BackgroundService } } - private async Task GuardarResultadosDeAmbitoAsync(EleccionesDbContext dbContext, int ambitoId, Elecciones.Core.DTOs.ResultadosDto resultadosDto, CancellationToken stoppingToken) + /// Realiza una operación "Upsert" (Update o Insert) de forma masiva y optimizada. + /// Este método es llamado por SondearResultadosMunicipalesAsync. + /// + private async Task GuardarResultadosDeMunicipiosAsync( + EleccionesDbContext dbContext, + Dictionary todosLosResultados, + CancellationToken stoppingToken) // <-- PARÁMETRO AÑADIDO { - var estadoRecuento = await dbContext.EstadosRecuentos.FindAsync(new object[] { ambitoId }, cancellationToken: stoppingToken); - if (estadoRecuento == null) + // Obtenemos los IDs de todos los ámbitos que vamos a actualizar. + var ambitoIds = todosLosResultados.Keys; + + // --- OPTIMIZACIÓN 1: Cargar todos los datos existentes en memoria UNA SOLA VEZ --- + var estadosRecuentoExistentes = await dbContext.EstadosRecuentos + .Where(e => ambitoIds.Contains(e.AmbitoGeograficoId)) + .ToDictionaryAsync(e => e.AmbitoGeograficoId, stoppingToken); + + var resultadosVotosExistentes = await dbContext.ResultadosVotos + .Where(rv => ambitoIds.Contains(rv.AmbitoGeograficoId)) + .GroupBy(rv => rv.AmbitoGeograficoId) + .ToDictionaryAsync(g => g.Key, g => g.ToDictionary(item => item.AgrupacionPoliticaId), stoppingToken); + + _logger.LogInformation("Procesando en memoria los resultados de {count} municipios.", todosLosResultados.Count); + + // --- OPTIMIZACIÓN 2: Procesar todo en memoria --- + foreach (var kvp in todosLosResultados) { - estadoRecuento = new EstadoRecuento { AmbitoGeograficoId = ambitoId }; - dbContext.EstadosRecuentos.Add(estadoRecuento); - } + var ambitoId = kvp.Key; + var resultadosDto = kvp.Value; - estadoRecuento.FechaTotalizacion = DateTime.Parse(resultadosDto.FechaTotalizacion).ToUniversalTime(); - estadoRecuento.MesasEsperadas = resultadosDto.EstadoRecuento.MesasEsperadas; - estadoRecuento.MesasTotalizadas = resultadosDto.EstadoRecuento.MesasTotalizadas; - estadoRecuento.MesasTotalizadasPorcentaje = resultadosDto.EstadoRecuento.MesasTotalizadasPorcentaje; - estadoRecuento.CantidadElectores = resultadosDto.EstadoRecuento.CantidadElectores; - estadoRecuento.CantidadVotantes = resultadosDto.EstadoRecuento.CantidadVotantes; - estadoRecuento.ParticipacionPorcentaje = resultadosDto.EstadoRecuento.ParticipacionPorcentaje; - - if (resultadosDto.ValoresTotalizadosOtros != null) - { - estadoRecuento.VotosEnBlanco = resultadosDto.ValoresTotalizadosOtros.VotosEnBlanco; - estadoRecuento.VotosEnBlancoPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosEnBlancoPorcentaje; - estadoRecuento.VotosNulos = resultadosDto.ValoresTotalizadosOtros.VotosNulos; - estadoRecuento.VotosNulosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosNulosPorcentaje; - estadoRecuento.VotosRecurridos = resultadosDto.ValoresTotalizadosOtros.VotosRecurridos; - estadoRecuento.VotosRecurridosPorcentaje = resultadosDto.ValoresTotalizadosOtros.VotosRecurridosPorcentaje; - } - - foreach (var votoPositivoDto in resultadosDto.ValoresTotalizadosPositivos) - { - var resultadoVoto = await dbContext.ResultadosVotos.FirstOrDefaultAsync( - rv => rv.AmbitoGeograficoId == ambitoId && rv.AgrupacionPoliticaId == votoPositivoDto.IdAgrupacion, - stoppingToken - ); - - if (resultadoVoto == null) + // Lógica Upsert para EstadoRecuento + if (!estadosRecuentoExistentes.TryGetValue(ambitoId, out var estadoRecuento)) { - resultadoVoto = new ResultadoVoto - { - AmbitoGeograficoId = ambitoId, - AgrupacionPoliticaId = votoPositivoDto.IdAgrupacion - }; - dbContext.ResultadosVotos.Add(resultadoVoto); + estadoRecuento = new EstadoRecuento { AmbitoGeograficoId = ambitoId }; + dbContext.EstadosRecuentos.Add(estadoRecuento); + } + + // Mapeo completo de propiedades para 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; + } + + // Lógica Upsert para ResultadosVotos + var votosDeAmbitoExistentes = resultadosVotosExistentes.GetValueOrDefault(ambitoId); + foreach (var votoPositivoDto in resultadosDto.ValoresTotalizadosPositivos) + { + ResultadoVoto? resultadoVoto = null; + if (votosDeAmbitoExistentes != null) + { + votosDeAmbitoExistentes.TryGetValue(votoPositivoDto.IdAgrupacion, out resultadoVoto); + } + + if (resultadoVoto == null) + { + resultadoVoto = new ResultadoVoto + { + AmbitoGeograficoId = ambitoId, + AgrupacionPoliticaId = votoPositivoDto.IdAgrupacion + }; + dbContext.ResultadosVotos.Add(resultadoVoto); + } + resultadoVoto.CantidadVotos = votoPositivoDto.Votos; + resultadoVoto.PorcentajeVotos = votoPositivoDto.VotosPorcentaje; } - resultadoVoto.CantidadVotos = votoPositivoDto.Votos; - resultadoVoto.PorcentajeVotos = votoPositivoDto.VotosPorcentaje; } + + // --- OPTIMIZACIÓN 3: Guardar todos los cambios en UNA SOLA TRANSACCIÓN --- + _logger.LogInformation("Guardando todos los cambios de resultados municipales en la base de datos..."); + // Ahora 'stoppingToken' es reconocido aquí await dbContext.SaveChangesAsync(stoppingToken); + _logger.LogInformation("Guardado completado."); } /// - /// Sondea la proyección de bancas para diputados y senadores. - /// Este método busca dinámicamente en la base de datos las categorías relevantes (Senadores/Diputados) - /// y los ámbitos de "Sección Electoral" (NivelId = 20), que es el nivel al que se reparten las bancas. - /// Luego, consulta la API para cada combinación y actualiza la tabla de proyecciones. + /// Sondea la proyección de bancas. Este método ahora es más completo: + /// 1. Consulta el reparto de bancas a nivel PROVINCIAL para cada categoría. + /// 2. Consulta el reparto de bancas desglosado por SECCIÓN ELECTORAL para cada categoría. /// - /// 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 { - // PASO 1: Preparar el entorno - // Creamos un scope de DbContext para esta operación específica, una buena práctica en servicios de larga duración. using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - // PASO 2: Obtener las categorías que reparten bancas (Senadores y Diputados) - // Hacemos una consulta a nuestra tabla local de categorías para que el código sea dinámico - // y no dependa de IDs fijos (hardcodeados). var categoriasDeBancas = await dbContext.CategoriasElectorales - .AsNoTracking() // Optimización de rendimiento: solo vamos a leer estos datos. + .AsNoTracking() .Where(c => c.Nombre.Contains("SENADORES") || c.Nombre.Contains("DIPUTADOS")) .ToListAsync(stoppingToken); - // Si por alguna razón estas categorías no están en la BD, no podemos continuar. - if (!categoriasDeBancas.Any()) - { - _logger.LogWarning("No se encontraron categorías para 'Senadores' o 'Diputados' en la BD. Omitiendo sondeo de bancas."); - return; - } + var provincia = await dbContext.AmbitosGeograficos + .AsNoTracking() + .FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken); - // PASO 3: Obtener las "Secciones Electorales" (NivelId 20) - // Esta es la corrección clave. Basado en la respuesta real de la API, las Secciones Electorales - // (Primera, Segunda, Tercera, etc.) usan NivelId = 20. var seccionesElectorales = await dbContext.AmbitosGeograficos .AsNoTracking() .Where(a => a.NivelId == 20 && a.DistritoId != null && a.SeccionProvincialId != null) .ToListAsync(stoppingToken); - // Si no se encuentra ninguna Sección Electoral en la BD (lo cual sería raro después de una sincronización exitosa), - // registramos una advertencia y salimos. - if (!seccionesElectorales.Any()) + if (!categoriasDeBancas.Any() || provincia == null) { - _logger.LogWarning("No se encontraron ámbitos de tipo 'Sección Electoral' (NivelId 20) en la BD para sondear bancas."); + _logger.LogWarning("No se encontraron categorías de bancas o el ámbito provincial en la BD. Omitiendo sondeo."); return; } - _logger.LogInformation("Iniciando sondeo de Bancas para {count} secciones electorales y {catCount} categorías...", seccionesElectorales.Count, categoriasDeBancas.Count); + _logger.LogInformation("Iniciando sondeo de Bancas a nivel Provincial y para {count} Secciones Electorales...", seccionesElectorales.Count); - // PASO 4: Iterar, consultar la API y preparar los datos para guardar - - // Esta bandera es crucial para la estrategia de "borrar y reemplazar". - // Nos asegura que la tabla de proyecciones se vacía UNA SOLA VEZ, justo antes de insertar - // el primer lote de datos nuevos, evitando datos inconsistentes. bool hasReceivedAnyNewData = false; + var nuevasProyecciones = new List(); - // Bucle externo: recorremos cada una de las 8 Secciones Electorales. + // --- NUEVA LÓGICA: Bucle para el nivel Provincial --- + foreach (var categoria in categoriasDeBancas) + { + if (stoppingToken.IsCancellationRequested) break; + + // Llamamos a la API sin 'seccionProvincialId' para obtener el total provincial. + var repartoBancas = await _apiService.GetBancasAsync(authToken, provincia.DistritoId!, null, categoria.Id); + + if (repartoBancas?.RepartoBancas is { Count: > 0 }) + { + hasReceivedAnyNewData = true; + foreach (var banca in repartoBancas.RepartoBancas) + { + // Guardamos la proyección asociándola al ID del ámbito de la provincia. + nuevasProyecciones.Add(new ProyeccionBanca + { + AmbitoGeograficoId = provincia.Id, + AgrupacionPoliticaId = banca.IdAgrupacion, + NroBancas = banca.NroBancas + }); + } + } + } + + // --- LÓGICA EXISTENTE: Bucle para el nivel de Sección Electoral --- foreach (var seccion in seccionesElectorales) { - if (stoppingToken.IsCancellationRequested) break; // Salida limpia si la aplicación se detiene. - - // Bucle interno: para cada sección, consultamos las bancas de Senadores y Diputados. + if (stoppingToken.IsCancellationRequested) break; foreach (var categoria in categoriasDeBancas) { if (stoppingToken.IsCancellationRequested) break; - // Llamamos a la API. El endpoint 'getBancas' requiere 'distritoId' y 'seccionProvincialId', - // que son precisamente los datos que tenemos en nuestros ámbitos de NivelId = 20. var repartoBancas = await _apiService.GetBancasAsync(authToken, seccion.DistritoId!, seccion.SeccionProvincialId!, categoria.Id); - // Verificamos que la respuesta de la API no sea nula y que contenga al menos una banca repartida. if (repartoBancas?.RepartoBancas is { Count: > 0 }) { - // Si esta es la PRIMERA VEZ en todo el sondeo que recibimos datos válidos... - if (!hasReceivedAnyNewData) - { - _logger.LogInformation("Se recibieron nuevos datos de bancas. Limpiando la tabla de proyecciones para la actualización..."); - // ...ejecutamos un comando SQL para borrar todos los datos viejos de la tabla. - await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ProyeccionesBancas", stoppingToken); - // Activamos la bandera para no volver a ejecutar este borrado. - hasReceivedAnyNewData = true; - } - - // Procesamos cada banca obtenida en la respuesta de la API. + hasReceivedAnyNewData = true; foreach (var banca in repartoBancas.RepartoBancas) { - // Creamos una nueva entidad 'ProyeccionBanca'. - var nuevaProyeccion = new ProyeccionBanca + nuevasProyecciones.Add(new ProyeccionBanca { AmbitoGeograficoId = seccion.Id, AgrupacionPoliticaId = banca.IdAgrupacion, NroBancas = banca.NroBancas - }; - // Y la añadimos al ChangeTracker de EF para que la inserte. - await dbContext.ProyeccionesBancas.AddAsync(nuevaProyeccion, stoppingToken); + }); } } } } - // PASO 5: Guardar los cambios en la Base de Datos - // Si la bandera 'hasReceivedAnyNewData' se activó, significa que hemos añadido nuevas proyecciones - // al DbContext y necesitamos persistirlas. + // --- LÓGICA DE GUARDADO CENTRALIZADA --- if (hasReceivedAnyNewData) { + _logger.LogInformation("Se recibieron {count} nuevos datos de bancas. Actualizando la tabla de proyecciones...", nuevasProyecciones.Count); + + // Usamos una transacción para asegurar la consistencia. + await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken); + + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ProyeccionesBancas", stoppingToken); + await dbContext.ProyeccionesBancas.AddRangeAsync(nuevasProyecciones, stoppingToken); await dbContext.SaveChangesAsync(stoppingToken); - _logger.LogInformation("Sondeo de Bancas completado. La tabla de proyecciones ha sido actualizada con nuevos datos."); + await transaction.CommitAsync(stoppingToken); + + _logger.LogInformation("Sondeo de Bancas completado. La tabla de proyecciones ha sido actualizada."); } else { - // Si no se recibieron datos nuevos, no hacemos nada en la BD. _logger.LogInformation("Sondeo de Bancas completado. No se encontraron datos nuevos de proyección, la tabla no fue modificada."); } } catch (Exception ex) { - // Capturamos cualquier error inesperado para que no detenga el worker por completo. _logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Bancas."); } }