diff --git a/OpenCand.API/Controllers/EstatisticaController.cs b/OpenCand.API/Controllers/EstatisticaController.cs new file mode 100644 index 0000000..d0a27f9 --- /dev/null +++ b/OpenCand.API/Controllers/EstatisticaController.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using OpenCand.API.Config; +using OpenCand.API.Model; +using OpenCand.API.Services; + +namespace OpenCand.API.Controllers +{ + [EnableRateLimiting(RateLimitingConfig.DefaultPolicy)] + public class EstatisticaController : BaseController + { + private readonly EstatisticaService estatisticaService; + + public EstatisticaController(EstatisticaService estatisticaService) + { + this.estatisticaService = estatisticaService; + } + + [HttpGet("configuration")] + public async Task GetConfiguration() + { + return await estatisticaService.GetConfigurationModel(); + } + + [HttpGet("enriquecimento")] + public async Task> GetMaioresEnriquecimentos() + { + return await estatisticaService.GetMaioresEnriquecimentos(); + } + + [HttpPost("values-sum")] + public async Task> GetValuesSum([FromBody] GetValueSumRequest getValueSumRequest) + { + return await estatisticaService.GetValueSum(getValueSumRequest); + } + } +} diff --git a/OpenCand.API/Model/ConfigurationModel.cs b/OpenCand.API/Model/ConfigurationModel.cs new file mode 100644 index 0000000..c3db7c6 --- /dev/null +++ b/OpenCand.API/Model/ConfigurationModel.cs @@ -0,0 +1,10 @@ +namespace OpenCand.API.Model +{ + public class ConfigurationModel + { + public List Partidos { get; set; } + public List SiglasUF { get; set; } + public List Anos { get; set; } + public List Cargos { get; set; } + } +} diff --git a/OpenCand.API/Model/EstatisticaModels.cs b/OpenCand.API/Model/EstatisticaModels.cs new file mode 100644 index 0000000..f8e545a --- /dev/null +++ b/OpenCand.API/Model/EstatisticaModels.cs @@ -0,0 +1,38 @@ +namespace OpenCand.API.Model +{ + public class MaioresEnriquecimento + { + public Guid IdCandidato { get; set; } + public string Nome { get; set; } + public float PatrimonioInicial { get; set; } + public int AnoInicial { get; set; } + public float PatrimonioFinal { get; set; } + public int AnoFinal { get; set; } + public float Enriquecimento { get; set; } + } + + public class GetValueSumRequest + { + public string Type { get; set; } // "bem", "despesa", "receita" + public string GroupBy { get; set; } // "candidato", "partido", "uf", or "cargo" + public GetValueSumRequestFilter? Filter { get; set; } + public class GetValueSumRequestFilter + { + public string? Partido { get; set; } // Optional, can be null + public string? Uf { get; set; } // Optional, can be null + public int? Ano { get; set; } // Optional, can be null + public string? Cargo { get; set; } // Optional, can be null + } + } + + public class GetValueSumResponse + { + public Guid? IdCandidato { get; set; } + public string? Sgpartido { get; set; } + public string? SiglaUf { get; set; } + public string? Cargo { get; set; } + public string? Nome { get; set; } + public int Ano { get; set; } + public decimal Valor { get; set; } + } +} diff --git a/OpenCand.API/Program.cs b/OpenCand.API/Program.cs index 4cf1cc6..e08575d 100644 --- a/OpenCand.API/Program.cs +++ b/OpenCand.API/Program.cs @@ -52,22 +52,21 @@ namespace OpenCand.API app.Run(); - } private static void SetupServices(WebApplicationBuilder builder) + } + + private static void SetupServices(WebApplicationBuilder builder) { builder.Services.Configure(builder.Configuration.GetSection("FotosSettings")); builder.Services.AddMemoryCache(); - // Register repositories with IMemoryCache - builder.Services.AddScoped(provider => - new OpenCandRepository(provider.GetRequiredService(), provider.GetService())); - builder.Services.AddScoped(provider => - new CandidatoRepository(provider.GetRequiredService(), provider.GetService())); - builder.Services.AddScoped(provider => - new BemCandidatoRepository(provider.GetRequiredService(), provider.GetService())); - builder.Services.AddScoped(provider => - new DespesaReceitaRepository(provider.GetRequiredService(), provider.GetService())); - + builder.Services.AddScoped(provider => new OpenCandRepository(provider.GetRequiredService(), provider.GetService())); + builder.Services.AddScoped(provider => new CandidatoRepository(provider.GetRequiredService(), provider.GetService())); + builder.Services.AddScoped(provider => new BemCandidatoRepository(provider.GetRequiredService(), provider.GetService())); + builder.Services.AddScoped(provider => new DespesaReceitaRepository(provider.GetRequiredService(), provider.GetService())); + builder.Services.AddScoped(provider => new EstatisticaRepository(provider.GetRequiredService(), provider.GetService())); + builder.Services.AddScoped(); + builder.Services.AddScoped(); } } } diff --git a/OpenCand.API/Repository/EstatisticaRepository.cs b/OpenCand.API/Repository/EstatisticaRepository.cs new file mode 100644 index 0000000..f8449c6 --- /dev/null +++ b/OpenCand.API/Repository/EstatisticaRepository.cs @@ -0,0 +1,87 @@ +using Dapper; +using Microsoft.Extensions.Caching.Memory; +using Npgsql; +using OpenCand.API.Model; +using OpenCand.Core.Models; +using OpenCand.Repository; + +namespace OpenCand.API.Repository +{ + public class EstatisticaRepository : BaseRepository + { + private readonly IConfiguration configuration; + + public EstatisticaRepository(IConfiguration configuration, IMemoryCache? cache = null) : base(configuration, cache) + { + this.configuration = configuration; + } + + public async Task> GetMaioresEnriquecimentos() + { + string cacheKey = GenerateCacheKey("GetMaioresEnriquecimento"); + + var result = await GetOrSetCacheAsync(cacheKey, async () => + { + using (var connection = new NpgsqlConnection(ConnectionString)) + { + return (await connection.QueryAsync(@" + WITH patrimonio_anual AS ( + SELECT idcandidato, ano, SUM(valor) AS valor_total_ano + FROM bem_candidato + GROUP BY idcandidato, ano + ), + extremos_declaracao AS ( + SELECT idcandidato, MIN(ano) AS anoInicial, MAX(ano) AS anoFinal + FROM patrimonio_anual + GROUP BY idcandidato + HAVING COUNT(DISTINCT ano) >= 2 + ) + SELECT + c.nome, + ed.anoInicial, + pi.valor_total_ano AS patrimonioInicial, + ed.anoFinal, + pf.valor_total_ano AS patrimonioFinal, + (pf.valor_total_ano - pi.valor_total_ano) AS enriquecimento + FROM extremos_declaracao ed + JOIN candidato c ON ed.idcandidato = c.idcandidato + JOIN patrimonio_anual pi ON ed.idcandidato = pi.idcandidato AND ed.anoInicial = pi.ano + JOIN patrimonio_anual pf ON ed.idcandidato = pf.idcandidato AND ed.anoFinal = pf.ano + ORDER BY enriquecimento DESC + LIMIT 25;") + ).AsList(); + } + }); return result ?? new List(); + } + + public async Task GetConfiguration() + { + string cacheKey = GenerateCacheKey("GetConfigurationModel"); + + var result = await GetOrSetCacheAsync(cacheKey, async () => + { + var result = new ConfigurationModel(); + + using (var connection = new NpgsqlConnection(ConnectionString)) + { + result.Partidos = (await connection.QueryAsync(@"SELECT DISTINCT sigla FROM partido;")).AsList(); + result.SiglasUF = (await connection.QueryAsync(@"SELECT DISTINCT siglauf FROM candidato_mapping;")).AsList(); + result.Anos = (await connection.QueryAsync(@"SELECT DISTINCT ano FROM candidato_mapping;")).AsList(); + result.Cargos = (await connection.QueryAsync(@"SELECT DISTINCT cargo FROM candidato_mapping;")).AsList(); + } + + return result; + }); + + return result ?? new ConfigurationModel(); + } + + public async Task> GetValueSum(string query, Dictionary? parameters = null) + { + using (var connection = new NpgsqlConnection(ConnectionString)) + { + return (await connection.QueryAsync(query, parameters)).AsList(); + } + } + } +} diff --git a/OpenCand.API/Repository/OpenCandRepository.cs b/OpenCand.API/Repository/OpenCandRepository.cs index a1c6057..6096e20 100644 --- a/OpenCand.API/Repository/OpenCandRepository.cs +++ b/OpenCand.API/Repository/OpenCandRepository.cs @@ -5,7 +5,8 @@ using OpenCand.Core.Models; using OpenCand.Repository; namespace OpenCand.API.Repository -{ public class OpenCandRepository : BaseRepository +{ + public class OpenCandRepository : BaseRepository { private readonly IConfiguration configuration; @@ -35,7 +36,9 @@ namespace OpenCand.API.Repository }); return result ?? new OpenCandStats(); - } public async Task GetDataAvailabilityAsync() + } + + public async Task GetDataAvailabilityAsync() { string cacheKey = GenerateCacheKey("DataAvailabilityStats"); diff --git a/OpenCand.API/Services/EstatisticaService.cs b/OpenCand.API/Services/EstatisticaService.cs new file mode 100644 index 0000000..8a4d1df --- /dev/null +++ b/OpenCand.API/Services/EstatisticaService.cs @@ -0,0 +1,178 @@ +using OpenCand.API.Model; +using OpenCand.API.Repository; +using OpenCand.Repository; + +namespace OpenCand.API.Services +{ + public class EstatisticaService + { + private readonly OpenCandRepository openCandRepository; + private readonly CandidatoRepository candidatoRepository; + private readonly BemCandidatoRepository bemCandidatoRepository; + private readonly DespesaReceitaRepository despesaReceitaRepository; + private readonly EstatisticaRepository estatisticaRepository; + private readonly IConfiguration configuration; + private readonly ILogger logger; + + public EstatisticaService( + OpenCandRepository openCandRepository, + CandidatoRepository candidatoRepository, + BemCandidatoRepository bemCandidatoRepository, + DespesaReceitaRepository despesaReceitaRepository, + EstatisticaRepository estatisticaRepository, + IConfiguration configuration, + ILogger logger) + { + this.openCandRepository = openCandRepository; + this.candidatoRepository = candidatoRepository; + this.bemCandidatoRepository = bemCandidatoRepository; + this.despesaReceitaRepository = despesaReceitaRepository; + this.estatisticaRepository = estatisticaRepository; + this.configuration = configuration; + this.logger = logger; + } + + public async Task GetConfigurationModel() + { + return await estatisticaRepository.GetConfiguration(); + } + + public async Task> GetMaioresEnriquecimentos() + { + return await estatisticaRepository.GetMaioresEnriquecimentos(); + } + + public async Task> GetValueSum(GetValueSumRequest request) + { + ValidateRequest(request); + + var sqlBuilder = new SqlQueryBuilder(); + var query = sqlBuilder.BuildQuery(request); + var parameters = sqlBuilder.GetParameters(); + + return await estatisticaRepository.GetValueSum(query, parameters); + } + + private void ValidateRequest(GetValueSumRequest request) + { + if (string.IsNullOrWhiteSpace(request.Type)) + throw new ArgumentException("Type is required."); + + if (string.IsNullOrWhiteSpace(request.GroupBy)) + throw new ArgumentException("GroupBy is required."); + + var validTypes = new[] { "bem", "despesa", "receita" }; + if (!validTypes.Contains(request.Type.ToLower())) + throw new ArgumentException($"Invalid type specified. Valid values are: {string.Join(", ", validTypes)}"); + + var validGroupBy = new[] { "candidato", "partido", "uf", "cargo" }; + if (!validGroupBy.Contains(request.GroupBy.ToLower())) + throw new ArgumentException($"Invalid groupBy specified. Valid values are: {string.Join(", ", validGroupBy)}"); + } + + private class SqlQueryBuilder + { + private readonly Dictionary _parameters = new(); + private int _paramCounter = 0; + + public Dictionary GetParameters() => _parameters; + + public string BuildQuery(GetValueSumRequest request) + { + var selectClause = BuildSelectClause(request.GroupBy); + var fromClause = BuildFromClause(request.Type); + var joinClause = BuildJoinClause(request.GroupBy); + var whereClause = BuildWhereClause(request.Filter); + var groupByClause = BuildGroupByClause(request.GroupBy); + var orderByClause = "ORDER BY SUM(src.valor) DESC"; + var limitClause = "LIMIT 10"; + + return $"{selectClause} {fromClause} {joinClause} {whereClause} {groupByClause} {orderByClause} {limitClause}"; + } + + private string BuildSelectClause(string groupBy) + { + return groupBy.ToLower() switch + { + "candidato" => "SELECT src.idcandidato, c.nome, src.ano, SUM(src.valor) as valor", + "partido" => "SELECT cm.sgpartido, src.ano, SUM(src.valor) as valor", + "uf" => "SELECT cm.siglauf, src.ano, SUM(src.valor) as valor", + "cargo" => "SELECT cm.cargo, src.ano, SUM(src.valor) as valor", + _ => throw new ArgumentException("Invalid group by specified.") + }; + } + + private string BuildFromClause(string type) + { + return type.ToLower() switch + { + "bem" => "FROM bem_candidato src", + "despesa" => "FROM despesas_candidato src", + "receita" => "FROM receitas_candidato src", + _ => throw new ArgumentException("Invalid type specified.") + }; + } + + private string BuildJoinClause(string groupBy) + { + return groupBy.ToLower() switch + { + "candidato" => "JOIN candidato c ON src.idcandidato = c.idcandidato JOIN candidato_mapping cm ON src.idcandidato = cm.idcandidato AND src.ano = cm.ano", + "partido" or "uf" or "cargo" => "JOIN candidato_mapping cm ON src.idcandidato = cm.idcandidato AND src.ano = cm.ano", + _ => throw new ArgumentException("Invalid group by specified.") + }; + } + + private string BuildWhereClause(GetValueSumRequest.GetValueSumRequestFilter? filter) + { + if (filter == null) + return ""; + + var conditions = new List(); + + if (!string.IsNullOrWhiteSpace(filter.Partido)) + { + var paramName = $"partido{++_paramCounter}"; + conditions.Add($"cm.sgpartido = @{paramName}"); + _parameters[paramName] = filter.Partido; + } + + if (!string.IsNullOrWhiteSpace(filter.Uf)) + { + var paramName = $"uf{++_paramCounter}"; + conditions.Add($"cm.siglauf = @{paramName}"); + _parameters[paramName] = filter.Uf; + } + + if (filter.Ano.HasValue) + { + var paramName = $"ano{++_paramCounter}"; + conditions.Add($"src.ano = @{paramName}"); + _parameters[paramName] = filter.Ano.Value; + } + + if (!string.IsNullOrWhiteSpace(filter.Cargo)) + { + var paramName = $"cargo{++_paramCounter}"; + conditions.Add($"cm.cargo = @{paramName}"); + _parameters[paramName] = filter.Cargo; + } + + return conditions.Count > 0 ? $"WHERE {string.Join(" AND ", conditions)}" : ""; + } + + private string BuildGroupByClause(string groupBy) + { + return groupBy.ToLower() switch + { + "candidato" => "GROUP BY src.idcandidato, c.nome, src.ano", + "partido" => "GROUP BY cm.sgpartido, src.ano", + "uf" => "GROUP BY cm.siglauf, src.ano", + "cargo" => "GROUP BY cm.cargo, src.ano", + _ => throw new ArgumentException("Invalid group by specified.") + }; + } + } + + } +} diff --git a/db/db.sql b/db/db.sql index 2f8fa94..32ac77b 100644 --- a/db/db.sql +++ b/db/db.sql @@ -98,7 +98,7 @@ CREATE INDEX idx_partido_numero ON partido (numero); ---- Tables for storing despesas e receitas of candidacies CREATE TABLE despesas_candidato ( - idreceita UUID NOT NULL DEFAULT gen_random_uuid(), + iddespesa UUID NOT NULL DEFAULT gen_random_uuid(), idcandidato UUID NOT NULL, ano INT NOT NULL, turno VARCHAR(2) NOT NULL, @@ -115,7 +115,7 @@ CREATE TABLE despesas_candidato ( descricao TEXT, origemdespesa TEXT, valor NUMERIC(20, 2), - CONSTRAINT pk_despesas_candidato PRIMARY KEY (idreceita), + CONSTRAINT pk_despesas_candidato PRIMARY KEY (iddespesa), CONSTRAINT fk_despesas_candidato_candidato FOREIGN KEY (idcandidato) REFERENCES candidato(idcandidato) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE INDEX idx_despesas_candidato_idcandidato ON despesas_candidato (idcandidato);