// src/Elecciones.Api/Controllers/ResultadosController.cs using Elecciones.Core.DTOs.ApiResponses; using Elecciones.Database; using Elecciones.Database.Entities; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Elecciones.Api.Controllers; [ApiController] [Route("api/[controller]")] public class ResultadosController : ControllerBase { private readonly EleccionesDbContext _dbContext; private readonly ILogger _logger; private readonly IConfiguration _configuration; public ResultadosController(EleccionesDbContext dbContext, ILogger logger, IConfiguration configuration) { _dbContext = dbContext; _logger = logger; _configuration = configuration; } [HttpGet("partido/{seccionId}")] public async Task GetResultadosPorPartido(string seccionId) { // 1. Buscamos el ámbito geográfico correspondiente al PARTIDO (Nivel 30) var ambito = await _dbContext.AmbitosGeograficos .AsNoTracking() // CAMBIO CLAVE: Buscamos por SeccionId y NivelId para ser precisos .FirstOrDefaultAsync(a => a.SeccionId == seccionId && a.NivelId == 30); if (ambito == null) { return NotFound(new { message = $"No se encontró el partido con ID {seccionId}" }); } // 2. Buscamos el estado del recuento para ese ámbito var estadoRecuento = await _dbContext.EstadosRecuentos .AsNoTracking() .FirstOrDefaultAsync(e => e.AmbitoGeograficoId == ambito.Id); if (estadoRecuento == null) { // Devolvemos una respuesta vacía pero válida para el frontend return Ok(new MunicipioResultadosDto { MunicipioNombre = ambito.Nombre, UltimaActualizacion = DateTime.UtcNow, PorcentajeEscrutado = 0, PorcentajeParticipacion = 0, Resultados = new List(), VotosAdicionales = new VotosAdicionalesDto() }); } // 3. Buscamos todos los votos para ese ámbito var resultadosVotos = await _dbContext.ResultadosVotos .AsNoTracking() .Include(rv => rv.AgrupacionPolitica) .Where(rv => rv.AmbitoGeograficoId == ambito.Id) .ToListAsync(); // 4. Calculamos el total de votos positivos long totalVotosPositivos = resultadosVotos.Sum(r => r.CantidadVotos); // 5. Mapeamos al DTO de respuesta var respuestaDto = new MunicipioResultadosDto { MunicipioNombre = ambito.Nombre, UltimaActualizacion = estadoRecuento.FechaTotalizacion, PorcentajeEscrutado = estadoRecuento.MesasTotalizadasPorcentaje, PorcentajeParticipacion = estadoRecuento.ParticipacionPorcentaje, Resultados = resultadosVotos.Select(rv => new AgrupacionResultadoDto { Nombre = rv.AgrupacionPolitica.Nombre, Votos = rv.CantidadVotos, Porcentaje = totalVotosPositivos > 0 ? (rv.CantidadVotos * 100.0m / totalVotosPositivos) : 0 }).OrderByDescending(r => r.Votos).ToList(), VotosAdicionales = new VotosAdicionalesDto { EnBlanco = estadoRecuento.VotosEnBlanco, Nulos = estadoRecuento.VotosNulos, Recurridos = estadoRecuento.VotosRecurridos } }; return Ok(respuestaDto); } [HttpGet("provincia/{distritoId}")] public async Task GetResultadosProvinciales(string distritoId) { var provincia = await _dbContext.AmbitosGeograficos.AsNoTracking() .FirstOrDefaultAsync(a => a.DistritoId == distritoId && a.NivelId == 10); var todosLosResumenes = await _dbContext.ResumenesVotos.AsNoTracking() .Include(r => r.AgrupacionPolitica) .ToListAsync(); // OBTENER TODOS LOS LOGOS EN UNA SOLA CONSULTA var logosLookup = (await _dbContext.LogosAgrupacionesCategorias.AsNoTracking().ToListAsync()) .ToLookup(l => $"{l.AgrupacionPoliticaId}-{l.CategoriaId}"); if (provincia == null) return NotFound($"No se encontró la provincia con distritoId {distritoId}"); var estadosPorCategoria = await _dbContext.EstadosRecuentosGenerales.AsNoTracking() .Include(e => e.CategoriaElectoral) .Where(e => e.AmbitoGeograficoId == provincia.Id) .ToDictionaryAsync(e => e.CategoriaId); var resultadosPorMunicipio = await _dbContext.ResultadosVotos .AsNoTracking() .Include(r => r.AgrupacionPolitica) .Where(r => r.AmbitoGeografico.NivelId == 30) .ToListAsync(); var resultadosAgrupados = resultadosPorMunicipio .GroupBy(r => r.CategoriaId) .Select(g => new { CategoriaId = g.Key, TotalVotosCategoria = g.Sum(r => r.CantidadVotos), // Agrupamos por el ID de la agrupación, no por el objeto Resultados = g.GroupBy(r => r.AgrupacionPoliticaId) .Select(partidoGroup => new { // El objeto Agrupacion lo tomamos del primer elemento del grupo Agrupacion = partidoGroup.First().AgrupacionPolitica, Votos = partidoGroup.Sum(r => r.CantidadVotos) }) .ToList() }) .Select(g => new { g.CategoriaId, CategoriaNombre = estadosPorCategoria.ContainsKey(g.CategoriaId) ? estadosPorCategoria[g.CategoriaId].CategoriaElectoral.Nombre : "Desconocido", EstadoRecuento = estadosPorCategoria.GetValueOrDefault(g.CategoriaId), Resultados = g.Resultados .Select(r => new { r.Agrupacion.Id, r.Agrupacion.Nombre, r.Agrupacion.NombreCorto, r.Agrupacion.Color, LogoUrl = logosLookup[$"{r.Agrupacion.Id}-{g.CategoriaId}"].FirstOrDefault()?.LogoUrl, r.Votos, VotosPorcentaje = g.TotalVotosCategoria > 0 ? ((decimal)r.Votos * 100 / g.TotalVotosCategoria) : 0 }) .OrderByDescending(r => r.Votos) .ToList() }) .OrderBy(c => c.CategoriaId) .ToList(); return Ok(resultadosAgrupados); } [HttpGet("bancas/{seccionId}")] public async Task GetBancasPorSeccion(string seccionId) { // 1. Buscamos el ámbito usando 'SeccionProvincialId'. // La API oficial usa este campo para las secciones electorales. // Además, el worker guarda estas secciones con NivelId = 20, por lo que lo usamos aquí para consistencia. var seccion = await _dbContext.AmbitosGeograficos .AsNoTracking() .FirstOrDefaultAsync(a => a.SeccionProvincialId == seccionId && a.NivelId == 20); // Nivel 20 = Sección Electoral Provincial if (seccion == null) { _logger.LogWarning("No se encontró la sección electoral con SeccionProvincialId: {SeccionId}", seccionId); return NotFound(new { message = $"No se encontró la sección electoral con ID {seccionId}" }); } // 2. Buscamos todas las proyecciones para ese ámbito (usando su clave primaria 'Id') var proyecciones = await _dbContext.ProyeccionesBancas .AsNoTracking() .Include(p => p.AgrupacionPolitica) .Where(p => p.AmbitoGeograficoId == seccion.Id) .Select(p => new { AgrupacionNombre = p.AgrupacionPolitica.Nombre, Bancas = p.NroBancas }) .OrderByDescending(p => p.Bancas) .ToListAsync(); if (!proyecciones.Any()) { // Este caso es posible si aún no hay proyecciones calculadas para esta sección. _logger.LogWarning("No se encontraron proyecciones de bancas para la sección: {SeccionNombre}", seccion.Nombre); return NotFound(new { message = $"No se han encontrado proyecciones de bancas para la sección {seccion.Nombre}" }); } // 3. Devolvemos la respuesta return Ok(new { SeccionNombre = seccion.Nombre, Proyeccion = proyecciones }); } [HttpGet("mapa")] public async Task GetResultadosParaMapa() { var maxVotosPorAmbito = _dbContext.ResultadosVotos .GroupBy(rv => rv.AmbitoGeograficoId) .Select(g => new { AmbitoId = g.Key, MaxVotos = g.Max(v => v.CantidadVotos) }); var resultadosGanadores = await _dbContext.ResultadosVotos .Join( maxVotosPorAmbito, voto => new { AmbitoId = voto.AmbitoGeograficoId, Votos = voto.CantidadVotos }, max => new { AmbitoId = max.AmbitoId, Votos = max.MaxVotos }, (voto, max) => voto ) .Include(rv => rv.AmbitoGeografico) .Include(rv => rv.AgrupacionPolitica) .Where(rv => rv.AmbitoGeografico.NivelId == 30) .Select(rv => new { AmbitoId = rv.AmbitoGeografico.Id, DepartamentoNombre = rv.AmbitoGeografico.Nombre, AgrupacionGanadoraId = rv.AgrupacionPoliticaId, ColorGanador = rv.AgrupacionPolitica.Color }) .ToListAsync(); return Ok(resultadosGanadores); } [HttpGet("municipio/{ambitoId}")] // Cambiamos el nombre del parámetro de ruta public async Task GetResultadosPorMunicipio(int ambitoId) // Cambiamos el tipo de string a int { _logger.LogInformation("Buscando resultados para AmbitoGeograficoId: {AmbitoId}", ambitoId); // PASO 1: Buscar el Ámbito Geográfico directamente por su CLAVE PRIMARIA (AmbitoGeograficoId). var ambito = await _dbContext.AmbitosGeograficos .AsNoTracking() .FirstOrDefaultAsync(a => a.Id == ambitoId && a.NivelId == 30); // Usamos a.Id == ambitoId if (ambito == null) { _logger.LogWarning("No se encontró el ámbito para el ID interno: {AmbitoId} o no es Nivel 30.", ambitoId); return NotFound(new { message = $"No se encontró el municipio con ID interno {ambitoId}" }); } _logger.LogInformation("Ámbito encontrado: Id={AmbitoId}, Nombre={AmbitoNombre}", ambito.Id, ambito.Nombre); // PASO 2: Usar la CLAVE PRIMARIA (ambito.Id) para buscar el estado del recuento. var estadoRecuento = await _dbContext.EstadosRecuentos .AsNoTracking() .FirstOrDefaultAsync(e => e.AmbitoGeograficoId == ambito.Id); if (estadoRecuento == null) { _logger.LogWarning("No se encontró EstadoRecuento para AmbitoGeograficoId: {AmbitoId}", ambito.Id); return NotFound(new { message = $"No se han encontrado resultados de recuento para el municipio {ambito.Nombre}" }); } // PASO 3: Usar la CLAVE PRIMARIA (ambito.Id) para buscar los votos. var resultadosVotos = await _dbContext.ResultadosVotos .AsNoTracking() .Include(rv => rv.AgrupacionPolitica) // Incluimos el nombre del partido .Where(rv => rv.AmbitoGeograficoId == ambito.Id) .OrderByDescending(rv => rv.CantidadVotos) .ToListAsync(); // PASO 4: Calcular el total de votos positivos para el porcentaje. long totalVotosPositivos = resultadosVotos.Sum(r => r.CantidadVotos); // PASO 5: Mapear todo al DTO de respuesta que el frontend espera. var respuestaDto = new MunicipioResultadosDto { MunicipioNombre = ambito.Nombre, UltimaActualizacion = estadoRecuento.FechaTotalizacion, PorcentajeEscrutado = estadoRecuento.MesasTotalizadasPorcentaje, PorcentajeParticipacion = estadoRecuento.ParticipacionPorcentaje, Resultados = resultadosVotos.Select(rv => new AgrupacionResultadoDto { Nombre = rv.AgrupacionPolitica.Nombre, Votos = rv.CantidadVotos, Porcentaje = totalVotosPositivos > 0 ? (rv.CantidadVotos * 100.0m / totalVotosPositivos) : 0 }).ToList(), VotosAdicionales = new VotosAdicionalesDto { EnBlanco = estadoRecuento.VotosEnBlanco, Nulos = estadoRecuento.VotosNulos, Recurridos = estadoRecuento.VotosRecurridos } }; return Ok(respuestaDto); } [HttpGet("composicion-congreso")] public async Task GetComposicionCongreso() { var config = await _dbContext.Configuraciones .AsNoTracking() .ToDictionaryAsync(c => c.Clave, c => c.Valor); // Aquí está el interruptor config.TryGetValue("UsarDatosDeBancadasOficiales", out var usarDatosOficialesValue); bool usarDatosOficiales = usarDatosOficialesValue == "true"; if (usarDatosOficiales) { // Si el interruptor está en 'true', llama a este método return await GetComposicionDesdeBancadasOficiales(config); } else { // Si está en 'false' o no existe, llama a este otro return await GetComposicionDesdeProyecciones(config); } } // En ResultadosController.cs private async Task GetComposicionDesdeBancadasOficiales(Dictionary config) { config.TryGetValue("MostrarOcupantes", out var mostrarOcupantesValue); bool mostrarOcupantes = mostrarOcupantesValue == "true"; IQueryable bancadasQuery = _dbContext.Bancadas.AsNoTracking() .Include(b => b.AgrupacionPolitica); if (mostrarOcupantes) { bancadasQuery = bancadasQuery.Include(b => b.Ocupante); } var bancadas = await bancadasQuery.ToListAsync(); var bancasPorAgrupacion = bancadas .Where(b => b.AgrupacionPolitica != null) // Agrupamos por el ID del partido, que es un valor único y estable .GroupBy(b => b.AgrupacionPolitica!.Id) .Select(g => { // Tomamos la información de la agrupación del primer elemento (todas son iguales) var primeraBancaDelGrupo = g.First(); var agrupacion = primeraBancaDelGrupo.AgrupacionPolitica!; // Filtramos los ocupantes solo para este grupo var ocupantesDelPartido = mostrarOcupantes ? g.Select(b => b.Ocupante).Where(o => o != null).ToList() : new List(); return new { Agrupacion = agrupacion, Camara = primeraBancaDelGrupo.Camara, BancasTotales = g.Count(), Ocupantes = ocupantesDelPartido }; }) .ToList(); // --- FIN DE LA CORRECCIÓN CLAVE --- var presidenteDiputados = bancasPorAgrupacion .Where(b => b.Camara == Core.Enums.TipoCamara.Diputados) .OrderByDescending(b => b.BancasTotales) .FirstOrDefault()?.Agrupacion; config.TryGetValue("PresidenciaSenadores", out var idPartidoPresidenteSenadores); var presidenteSenadores = await _dbContext.AgrupacionesPoliticas.FindAsync(idPartidoPresidenteSenadores); object MapearPartidos(Core.Enums.TipoCamara camara) { var partidosDeCamara = bancasPorAgrupacion.Where(b => b.Camara == camara); if (camara == Core.Enums.TipoCamara.Diputados) partidosDeCamara = partidosDeCamara.OrderBy(p => p.Agrupacion.OrdenDiputados ?? 999); else partidosDeCamara = partidosDeCamara.OrderBy(p => p.Agrupacion.OrdenSenadores ?? 999); return partidosDeCamara .OrderByDescending(p => p.BancasTotales) .Select(p => new { p.Agrupacion.Id, p.Agrupacion.Nombre, p.Agrupacion.NombreCorto, p.Agrupacion.Color, p.BancasTotales, p.Ocupantes // Pasamos la lista de ocupantes ya filtrada }).ToList(); } var diputados = new { CamaraNombre = "Cámara de Diputados", TotalBancas = 92, BancasEnJuego = 0, Partidos = MapearPartidos(Core.Enums.TipoCamara.Diputados), PresidenteBancada = presidenteDiputados != null ? new { presidenteDiputados.Color } : null }; var senadores = new { CamaraNombre = "Cámara de Senadores", TotalBancas = 46, BancasEnJuego = 0, Partidos = MapearPartidos(Core.Enums.TipoCamara.Senadores), PresidenteBancada = presidenteSenadores != null ? new { presidenteSenadores.Color } : null }; return Ok(new { Diputados = diputados, Senadores = senadores }); } private async Task GetComposicionDesdeProyecciones(Dictionary config) { var bancasPorAgrupacion = await _dbContext.ProyeccionesBancas .AsNoTracking() .GroupBy(p => new { p.AgrupacionPoliticaId, p.CategoriaId }) .Select(g => new { AgrupacionId = g.Key.AgrupacionPoliticaId, CategoriaId = g.Key.CategoriaId, BancasTotales = g.Sum(p => p.NroBancas) }) .ToListAsync(); var todasAgrupaciones = await _dbContext.AgrupacionesPoliticas.AsNoTracking().ToDictionaryAsync(a => a.Id); config.TryGetValue("PresidenciaSenadores", out var idPartidoPresidenteSenadores); todasAgrupaciones.TryGetValue(idPartidoPresidenteSenadores ?? "", out var presidenteSenadores); string? idPartidoPresidenteDiputados = bancasPorAgrupacion .Where(b => b.CategoriaId == 6) .OrderByDescending(b => b.BancasTotales) .FirstOrDefault()?.AgrupacionId; todasAgrupaciones.TryGetValue(idPartidoPresidenteDiputados ?? "", out var presidenteDiputados); object MapearPartidos(int categoriaId) { var partidosDeCamara = bancasPorAgrupacion .Where(b => b.CategoriaId == categoriaId && b.BancasTotales > 0) .Select(b => new { Bancas = b, Agrupacion = todasAgrupaciones[b.AgrupacionId] }); if (categoriaId == 6) // Diputados partidosDeCamara = partidosDeCamara.OrderBy(b => b.Agrupacion.OrdenDiputados ?? 999) .ThenByDescending(b => b.Bancas.BancasTotales); else // Senadores partidosDeCamara = partidosDeCamara.OrderBy(b => b.Agrupacion.OrdenSenadores ?? 999) .ThenByDescending(b => b.Bancas.BancasTotales); return partidosDeCamara .Select(b => new { b.Agrupacion.Id, b.Agrupacion.Nombre, b.Agrupacion.NombreCorto, b.Agrupacion.Color, b.Bancas.BancasTotales, Ocupantes = new List() // <-- Siempre vacío en modo proyección }) .ToList(); } var diputados = new { CamaraNombre = "Cámara de Diputados", TotalBancas = 92, BancasEnJuego = 46, Partidos = MapearPartidos(6), PresidenteBancada = presidenteDiputados != null ? new { presidenteDiputados.Color } : null }; var senadores = new { CamaraNombre = "Cámara de Senadores", TotalBancas = 46, BancasEnJuego = 23, Partidos = MapearPartidos(5), PresidenteBancada = presidenteSenadores != null ? new { presidenteSenadores.Color } : null }; return Ok(new { Diputados = diputados, Senadores = senadores }); } [HttpGet("bancadas-detalle")] public async Task GetBancadasConOcupantes() { var config = await _dbContext.Configuraciones.AsNoTracking().ToDictionaryAsync(c => c.Clave, c => c.Valor); config.TryGetValue("UsarDatosDeBancadasOficiales", out var usarDatosOficialesValue); if (usarDatosOficialesValue != "true") { // Si el modo oficial no está activo, SIEMPRE devolvemos un array vacío. return Ok(new List()); } // Si el modo oficial SÍ está activo, devolvemos los detalles. var bancadasConOcupantes = await _dbContext.Bancadas .AsNoTracking() .Include(b => b.Ocupante) .Select(b => new { b.Id, b.Camara, b.NumeroBanca, b.AgrupacionPoliticaId, Ocupante = b.Ocupante }) .OrderBy(b => b.Id) .ToListAsync(); return Ok(bancadasConOcupantes); } [HttpGet("configuracion-publica")] public async Task GetConfiguracionPublica() { // Definimos una lista de las claves de configuración que son seguras para el público. // De esta manera, si en el futuro añadimos claves sensibles (como contraseñas de API, etc.), // nunca se expondrán accidentalmente. var clavesPublicas = new List { "TickerResultadosCantidad", "ConcejalesResultadosCantidad" // "OtraClavePublica" }; var configuracionPublica = await _dbContext.Configuraciones .AsNoTracking() .Where(c => clavesPublicas.Contains(c.Clave)) .ToDictionaryAsync(c => c.Clave, c => c.Valor); return Ok(configuracionPublica); } [HttpGet("concejales/{seccionId}")] public async Task GetResultadosConcejalesPorSeccion(string seccionId) { // 1. Encontrar todos los municipios (Nivel 30) que pertenecen a la sección dada (Nivel 20) var municipiosDeLaSeccion = await _dbContext.AmbitosGeograficos .AsNoTracking() .Where(a => a.NivelId == 30 && a.SeccionProvincialId == seccionId) .Select(a => a.Id) // Solo necesitamos sus IDs .ToListAsync(); if (!municipiosDeLaSeccion.Any()) { return Ok(new List()); } // 2. Obtener todos los resultados de la categoría Concejales (ID 7) para esos municipios var resultadosMunicipales = await _dbContext.ResultadosVotos .AsNoTracking() .Include(r => r.AgrupacionPolitica) .Where(r => r.CategoriaId == 7 && municipiosDeLaSeccion.Contains(r.AmbitoGeograficoId)) .ToListAsync(); var logosConcejales = await _dbContext.LogosAgrupacionesCategorias .AsNoTracking() .Where(l => l.CategoriaId == 7) .ToDictionaryAsync(l => l.AgrupacionPoliticaId); // 3. Agrupar y sumar en memoria para obtener el total por partido para la sección var totalVotosSeccion = resultadosMunicipales.Sum(r => r.CantidadVotos); var resultadosFinales = resultadosMunicipales .GroupBy(r => r.AgrupacionPolitica) .Select(g => new { Agrupacion = g.Key, Votos = g.Sum(r => r.CantidadVotos) }) .OrderByDescending(r => r.Votos) .Select(r => new { r.Agrupacion.Id, r.Agrupacion.Nombre, r.Agrupacion.NombreCorto, r.Agrupacion.Color, LogoUrl = logosConcejales.GetValueOrDefault(r.Agrupacion.Id)?.LogoUrl, r.Votos, votosPorcentaje = totalVotosSeccion > 0 ? ((decimal)r.Votos * 100 / totalVotosSeccion) : 0 }) .ToList(); return Ok(resultadosFinales); } }