diff --git a/.env.example b/.env.example index 702f824..cc910f5 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ ES_URL=http://seu-elasticsearch:9200 ES_INDEX=atuacapes ES_USER=seu_usuario_elastic ES_PASSWORD=sua_senha_elastic +ES_VERIFY_SSL=true -ORACLE_USER=FREDERICOAC -ORACLE_PASSWORD=FREDEricoac -ORACLE_DSN=oracledhtsrv02.hom.capes.gov.br:1521/hom_dr +SCHEDULER_ENABLED=false +SCHEDULER_HOUR=3 diff --git a/backend/.env.example b/backend/.env.example index b47b6cf..52e4cea 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,11 +2,7 @@ ES_URL=http://localhost:9200 ES_INDEX=atuacapes__1763197236 ES_USER=seu_usuario_elastic ES_PASSWORD=sua_senha_elastic -ES_DEFAULT_SOURCE_FIELDS=id,dadosPessoais.nome,dadosPessoais.cpf,atuacoes - -ORACLE_USER=seu_usuario -ORACLE_PASSWORD=sua_senha -ORACLE_DSN=host:1521/service_name +ES_VERIFY_SSL=true API_HOST=0.0.0.0 API_PORT=8000 @@ -14,3 +10,5 @@ API_RELOAD=true CORS_ORIGINS=http://localhost:3000,http://localhost:5173 LOG_LEVEL=INFO +SCHEDULER_ENABLED=false +SCHEDULER_HOUR=3 diff --git a/backend/Dockerfile b/backend/Dockerfile index 1c124b6..37c6a11 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,23 +2,6 @@ FROM python:3.11-slim WORKDIR /app -RUN apt-get update && apt-get install -y \ - gcc \ - wget \ - unzip \ - && (apt-get install -y libaio1t64 || apt-get install -y libaio1 || true) \ - && rm -rf /var/lib/apt/lists/* - -RUN wget https://download.oracle.com/otn_software/linux/instantclient/2115000/instantclient-basic-linux.x64-21.15.0.0.0dbru.zip \ - && unzip instantclient-basic-linux.x64-21.15.0.0.0dbru.zip -d /opt/oracle \ - && rm instantclient-basic-linux.x64-21.15.0.0.0dbru.zip \ - && sh -c "echo /opt/oracle/instantclient_21_15 > /etc/ld.so.conf.d/oracle-instantclient.conf" \ - && ln -sf /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1 || true \ - && ldconfig - -ENV LD_LIBRARY_PATH=/opt/oracle/instantclient_21_15:$LD_LIBRARY_PATH -ENV PATH=/opt/oracle/instantclient_21_15:$PATH - RUN pip install --upgrade pip COPY requirements.txt ./ diff --git a/backend/pyproject.toml b/backend/pyproject.toml index bf0dbd5..1d8c0eb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,8 +11,6 @@ fastapi = "^0.109.0" uvicorn = {extras = ["standard"], version = "^0.27.0"} pydantic = "^2.5.3" pydantic-settings = "^2.1.0" -elasticsearch = "^8.11.1" -cx-Oracle = "^8.3.0" python-dateutil = "^2.8.2" httpx = "^0.26.0" python-dotenv = "^1.0.0" diff --git a/backend/requirements.txt b/backend/requirements.txt index ca29160..271765e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,8 +2,7 @@ fastapi==0.109.0 uvicorn[standard]==0.27.0 pydantic==2.5.3 pydantic-settings==2.1.0 -cx-Oracle==8.3.0 python-dateutil==2.8.2 httpx==0.26.0 python-dotenv==1.0.0 -aiohttp==3.9.1 +rich==13.7.0 diff --git a/backend/src/application/dtos/consultor_dto.py b/backend/src/application/dtos/consultor_dto.py index 241497f..61ae32e 100644 --- a/backend/src/application/dtos/consultor_dto.py +++ b/backend/src/application/dtos/consultor_dto.py @@ -18,6 +18,7 @@ class CoordenacaoCapesDTO: periodo: PeriodoDTO areas_adicionais: List[str] ja_coordenou_antes: bool + presidente: bool @dataclass @@ -54,6 +55,7 @@ class PremiacaoDTO: tipo: str nome_premio: str ano: int + papel: Optional[str] = None @dataclass @@ -77,6 +79,9 @@ class OrientacaoDTO: tipo: str nivel: str ano: Optional[int] + coorientacao: bool = False + premiada: bool = False + premiacao_tipo: Optional[str] = None @dataclass @@ -130,10 +135,10 @@ class ConsultorResumoDTO: class ConsultorDetalhadoDTO: id_pessoa: int nome: str - cpf: Optional[str] anos_atuacao: float ativo: bool veterano: bool + coordenador_ppg: bool coordenacoes_capes: List[CoordenacaoCapesDTO] consultoria: Optional[ConsultoriaDTO] inscricoes: List[InscricaoDTO] diff --git a/backend/src/application/jobs/processar_ranking.py b/backend/src/application/jobs/processar_ranking.py index 9634b77..8047ab0 100644 --- a/backend/src/application/jobs/processar_ranking.py +++ b/backend/src/application/jobs/processar_ranking.py @@ -1,15 +1,11 @@ -import json import logging -from datetime import datetime -from typing import Optional, Dict, Any +from typing import Dict, Any, List from ...infrastructure.elasticsearch.client import ElasticsearchClient +from ...infrastructure.ranking_store import RankingEntry, RankingStore logger = logging.getLogger(__name__) -from ...infrastructure.oracle.client import OracleClient -from ...infrastructure.oracle.ranking_repository import RankingOracleRepository from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl -from ...domain.services.calculador_pontuacao import CalculadorPontuacao from .job_status import job_status @@ -17,16 +13,12 @@ class ProcessarRankingJob: def __init__( self, es_client: ElasticsearchClient, - oracle_remote_client: OracleClient, - oracle_local_client: OracleClient, - ranking_repo: RankingOracleRepository, + ranking_store: RankingStore, ): self.es_client = es_client - self.oracle_remote_client = oracle_remote_client - self.oracle_local_client = oracle_local_client - self.ranking_repo = ranking_repo + self.ranking_store = ranking_store self.consultor_repo = ConsultorRepositoryImpl(es_client, oracle_client=None) - self.calculador = CalculadorPontuacao() + self._consultores: List[dict] = [] async def executar(self, limpar_antes: bool = True) -> Dict[str, Any]: if job_status.is_running: @@ -36,28 +28,26 @@ class ProcessarRankingJob: total = await self.es_client.contar_com_atuacoes() job_status.iniciar(total_esperado=total) - if limpar_antes: - job_status.mensagem = "Limpando tabela de ranking..." - self.ranking_repo.limpar_tabela() - - job_status.mensagem = "Iniciando processamento via Scroll API..." + self._consultores = [] + job_status.mensagem = "Iniciando processamento do ranking via Scroll API (Elasticsearch)..." resultado = await self.es_client.buscar_todos_consultores( callback=self._processar_batch, batch_size=5000 ) - job_status.mensagem = "Atualizando posições no ranking..." - self.ranking_repo.atualizar_posicoes() + job_status.mensagem = "Ordenando e gerando posições..." + entries = self._gerar_entries_ordenadas(self._consultores) + await self.ranking_store.set_entries(entries) - estatisticas = self.ranking_repo.obter_estatisticas() + estatisticas = self._obter_estatisticas(entries) job_status.finalizar(sucesso=True) return { "sucesso": True, - "total_processados": resultado["processados"], - "total_batches": resultado["batches"], + "total_processados": resultado.get("processados", len(entries)), + "total_batches": resultado.get("batches", 0), "tempo_decorrido": job_status.tempo_decorrido, "estatisticas": estatisticas } @@ -67,26 +57,11 @@ class ProcessarRankingJob: raise RuntimeError(f"Erro ao processar ranking: {e}") async def _processar_batch(self, docs: list, progress: dict) -> None: - consultores_para_inserir = [] - for doc in docs: try: consultor = await self.consultor_repo._construir_consultor(doc) - consultor_dict = { - "id_pessoa": consultor.id_pessoa, - "nome": consultor.nome, - "pontuacao_total": consultor.pontuacao_total, - "componente_a": consultor.pontuacao_bloco_a, - "componente_b": 0, - "componente_c": consultor.pontuacao_bloco_c, - "componente_d": consultor.pontuacao_bloco_d, - "ativo": consultor.ativo, - "anos_atuacao": consultor.anos_atuacao, - "detalhes": self._gerar_json_detalhes(consultor) - } - - consultores_para_inserir.append(consultor_dict) + self._consultores.append(self._gerar_json_detalhes(consultor)) except Exception as e: import traceback @@ -94,9 +69,6 @@ class ProcessarRankingJob: logger.debug(f"Traceback: {traceback.format_exc()}") continue - if consultores_para_inserir: - self.ranking_repo.inserir_batch(consultores_para_inserir) - job_status.atualizar_progresso( processados=progress["processados"], batch_atual=progress["batch_atual"], @@ -104,10 +76,29 @@ class ProcessarRankingJob: ) def _gerar_json_detalhes(self, consultor) -> dict: + bloco_b = 0 # reservado no V1 (dados incompletos) + pontuacao = consultor.pontuacao.to_dict() if consultor.pontuacao else None + if isinstance(pontuacao, dict): + pontuacao = dict(pontuacao) + pontuacao["bloco_b"] = {"bloco": "B", "total": bloco_b, "atuacoes": []} + pontuacao["pontuacao_total"] = ( + pontuacao.get("pontuacao_total", 0) + bloco_b + if isinstance(pontuacao.get("pontuacao_total"), (int, float)) + else consultor.pontuacao_total + bloco_b + ) + return { "id_pessoa": consultor.id_pessoa, "nome": consultor.nome, - "cpf": consultor.cpf, + "posicao": None, + "pontuacao_total": consultor.pontuacao_total + bloco_b, + "bloco_a": consultor.pontuacao_bloco_a, + "bloco_b": bloco_b, + "bloco_c": consultor.pontuacao_bloco_c, + "bloco_d": consultor.pontuacao_bloco_d, + "ativo": consultor.ativo, + "anos_atuacao": consultor.anos_atuacao, + "coordenador_ppg": consultor.coordenador_ppg, "coordenacoes_capes": [ { "codigo": c.codigo, @@ -115,7 +106,8 @@ class ProcessarRankingJob: "area_avaliacao": c.area_avaliacao, "inicio": c.periodo.inicio.isoformat() if c.periodo.inicio else None, "fim": c.periodo.fim.isoformat() if c.periodo.fim else None, - "ativo": c.periodo.ativo + "ativo": c.periodo.ativo, + "presidente": c.presidente, } for c in consultor.coordenacoes_capes ], @@ -153,7 +145,8 @@ class ProcessarRankingJob: "codigo": p.codigo, "tipo": p.tipo, "nome_premio": p.nome_premio, - "ano": p.ano + "ano": p.ano, + "papel": p.papel, } for p in consultor.premiacoes ], @@ -179,7 +172,10 @@ class ProcessarRankingJob: "codigo": o.codigo, "tipo": o.tipo, "nivel": o.nivel, - "ano": o.ano + "ano": o.ano, + "coorientacao": o.coorientacao, + "premiada": o.premiada, + "premiacao_tipo": o.premiacao_tipo, } for o in consultor.orientacoes ], @@ -192,5 +188,68 @@ class ProcessarRankingJob: } for m in consultor.membros_banca ], - "pontuacao": consultor.pontuacao.to_dict() if consultor.pontuacao else None + "pontuacao": pontuacao, + } + + @staticmethod + def _gerar_entries_ordenadas(consultores: List[dict]) -> List[RankingEntry]: + consultores_ordenados = sorted( + consultores, + key=lambda c: (int(c.get("pontuacao_total", 0)), -int(c.get("id_pessoa", 0))), + reverse=True, + ) + entries: List[RankingEntry] = [] + for idx, c in enumerate(consultores_ordenados, start=1): + c["posicao"] = idx + entries.append( + RankingEntry( + id_pessoa=int(c["id_pessoa"]), + nome=str(c.get("nome", "")), + posicao=idx, + pontuacao_total=int(c.get("pontuacao_total", 0)), + bloco_a=int(c.get("bloco_a", 0)), + bloco_b=int(c.get("bloco_b", 0)), + bloco_c=int(c.get("bloco_c", 0)), + bloco_d=int(c.get("bloco_d", 0)), + ativo=bool(c.get("ativo", False)), + anos_atuacao=float(c.get("anos_atuacao", 0) or 0), + detalhes=c, + ) + ) + return entries + + @staticmethod + def _obter_estatisticas(entries: List[RankingEntry]) -> Dict[str, Any]: + if not entries: + return { + "total_consultores": 0, + "total_ativos": 0, + "total_inativos": 0, + "ultima_atualizacao": None, + "pontuacao_media": 0, + "pontuacao_maxima": 0, + "pontuacao_minima": 0, + "media_componentes": {"a": 0, "b": 0, "c": 0, "d": 0}, + } + + total = len(entries) + ativos = sum(1 for e in entries if e.ativo) + inativos = total - ativos + totais = [e.pontuacao_total for e in entries] + media_total = sum(totais) / total if total else 0 + + return { + "total_consultores": total, + "total_ativos": ativos, + "total_inativos": inativos, + "ultima_atualizacao": None, + "pontuacao_media": float(round(media_total, 2)), + "pontuacao_maxima": float(max(totais) if totais else 0), + "pontuacao_minima": float(min(totais) if totais else 0), + "media_componentes": { + "a": float(round(sum(e.bloco_a for e in entries) / total, 2)), + "b": float(round(sum(e.bloco_b for e in entries) / total, 2)), + "c": float(round(sum(e.bloco_c for e in entries) / total, 2)), + "d": float(round(sum(e.bloco_d for e in entries) / total, 2)), + }, } diff --git a/backend/src/application/jobs/scheduler.py b/backend/src/application/jobs/scheduler.py index e1ac3c9..29e14e5 100644 --- a/backend/src/application/jobs/scheduler.py +++ b/backend/src/application/jobs/scheduler.py @@ -4,15 +4,13 @@ from datetime import datetime, time, timedelta from typing import Optional from .processar_ranking import ProcessarRankingJob -from .popular_componente_b_job import PopularComponenteBJob logger = logging.getLogger(__name__) class RankingScheduler: - def __init__(self, job: ProcessarRankingJob, job_componente_b: PopularComponenteBJob | None = None): + def __init__(self, job: ProcessarRankingJob): self.job = job - self.job_componente_b = job_componente_b self.task: Optional[asyncio.Task] = None self.running = False @@ -45,10 +43,6 @@ class RankingScheduler: logger.info("Executando job de ranking automático") await self.job.executar(limpar_antes=True) - if self.job_componente_b: - logger.info("Executando popular_componente_b após ranking") - await asyncio.to_thread(self.job_componente_b.executar) - except asyncio.CancelledError: logger.info("Scheduler cancelado") break diff --git a/backend/src/application/use_cases/obter_consultor.py b/backend/src/application/use_cases/obter_consultor.py index ee5594b..f968939 100644 --- a/backend/src/application/use_cases/obter_consultor.py +++ b/backend/src/application/use_cases/obter_consultor.py @@ -10,14 +10,16 @@ class ObterConsultorUseCase: self.repository = repository self.ranking_use_case = ObterRankingUseCase(repository) - async def executar(self, id_pessoa: int) -> Optional[ConsultorDetalhadoDTO]: + async def executar(self, id_pessoa: int, rank: Optional[int] = None) -> Optional[ConsultorDetalhadoDTO]: consultor = await self.repository.buscar_por_id(id_pessoa) if not consultor: return None - ranking_completo = await self.repository.buscar_ranking(limite=1000) - rank = next( - (idx + 1 for idx, c in enumerate(ranking_completo) if c.id_pessoa == id_pessoa), None - ) + if rank is None: + # Fallback legado: pode ser incompleto em grandes bases. + ranking_completo = await self.repository.buscar_ranking(limite=1000) + rank = next( + (idx + 1 for idx, c in enumerate(ranking_completo) if c.id_pessoa == id_pessoa), None + ) return self.ranking_use_case._converter_para_dto_detalhado(consultor, rank or 0) diff --git a/backend/src/application/use_cases/obter_ranking.py b/backend/src/application/use_cases/obter_ranking.py index d12e547..b6e34df 100644 --- a/backend/src/application/use_cases/obter_ranking.py +++ b/backend/src/application/use_cases/obter_ranking.py @@ -58,10 +58,10 @@ class ObterRankingUseCase: return ConsultorDetalhadoDTO( id_pessoa=consultor.id_pessoa, nome=consultor.nome, - cpf=consultor.cpf, anos_atuacao=consultor.anos_atuacao, ativo=consultor.ativo, veterano=consultor.veterano, + coordenador_ppg=consultor.coordenador_ppg, coordenacoes_capes=[ CoordenacaoCapesDTO( codigo=cc.codigo, @@ -75,6 +75,7 @@ class ObterRankingUseCase: ), areas_adicionais=cc.areas_adicionais, ja_coordenou_antes=cc.ja_coordenou_antes, + presidente=cc.presidente, ) for cc in consultor.coordenacoes_capes ], @@ -117,6 +118,7 @@ class ObterRankingUseCase: tipo=p.tipo, nome_premio=p.nome_premio, ano=p.ano, + papel=p.papel, ) for p in consultor.premiacoes ], @@ -143,6 +145,9 @@ class ObterRankingUseCase: tipo=o.tipo, nivel=o.nivel, ano=o.ano, + coorientacao=o.coorientacao, + premiada=o.premiada, + premiacao_tipo=o.premiacao_tipo, ) for o in consultor.orientacoes ], diff --git a/backend/src/domain/entities/consultor.py b/backend/src/domain/entities/consultor.py index 1913e98..f8c6c3f 100644 --- a/backend/src/domain/entities/consultor.py +++ b/backend/src/domain/entities/consultor.py @@ -14,6 +14,7 @@ class CoordenacaoCapes: periodo: Periodo areas_adicionais: List[str] = field(default_factory=list) ja_coordenou_antes: bool = False + presidente: bool = False @dataclass @@ -51,6 +52,7 @@ class Premiacao: tipo: str nome_premio: str ano: int + papel: Optional[str] = None @dataclass @@ -74,6 +76,9 @@ class Orientacao: tipo: str nivel: str ano: Optional[int] = None + coorientacao: bool = False + premiada: bool = False + premiacao_tipo: Optional[str] = None @dataclass @@ -89,6 +94,7 @@ class Consultor: id_pessoa: int nome: str cpf: Optional[str] = None + coordenador_ppg: bool = False coordenacoes_capes: List[CoordenacaoCapes] = field(default_factory=list) consultoria: Optional[Consultoria] = None inscricoes: List[Inscricao] = field(default_factory=list) diff --git a/backend/src/domain/services/calculador_pontuacao.py b/backend/src/domain/services/calculador_pontuacao.py index 102296b..2a1f4b1 100644 --- a/backend/src/domain/services/calculador_pontuacao.py +++ b/backend/src/domain/services/calculador_pontuacao.py @@ -89,10 +89,19 @@ class CalculadorPontuacao: tempo = min(anos_total * criterio.multiplicador_tempo, criterio.teto_tempo) bonus = 0 - if consultoria.anos_consecutivos >= 8 and criterio.bonus_continuidade_8anos: - bonus += criterio.bonus_continuidade_8anos - if codigo == "CONS_ATIVO" and consultoria.retornos > 0 and criterio.bonus_retorno: - bonus += criterio.bonus_retorno + + # Bônus de continuidade (escalonado, não cumulativo) - apenas CONS_ATIVO + if codigo == "CONS_ATIVO": + if consultoria.anos_consecutivos >= 8: + bonus += 15 + elif consultoria.anos_consecutivos >= 5: + bonus += 10 + elif consultoria.anos_consecutivos >= 3: + bonus += 5 + + # Bônus de retorno (uma vez) - apenas CONS_ATIVO + if consultoria.retornos > 0 and criterio.bonus_retorno: + bonus += criterio.bonus_retorno total_bruto = base + tempo + bonus total = min(total_bruto, criterio.teto) if criterio.teto > 0 else total_bruto diff --git a/backend/src/domain/value_objects/criterios_pontuacao.py b/backend/src/domain/value_objects/criterios_pontuacao.py index 368a1af..f5ebba9 100644 --- a/backend/src/domain/value_objects/criterios_pontuacao.py +++ b/backend/src/domain/value_objects/criterios_pontuacao.py @@ -101,7 +101,6 @@ CRITERIOS: Dict[str, CriterioPontuacao] = { pontua_tempo=True, multiplicador_tempo=5, teto_tempo=50, - bonus_continuidade_8anos=20, bonus_retorno=15, ), "CONS_HIST": CriterioPontuacao( @@ -113,7 +112,6 @@ CRITERIOS: Dict[str, CriterioPontuacao] = { pontua_tempo=True, multiplicador_tempo=5, teto_tempo=50, - bonus_continuidade_8anos=20, ), "CONS_FALECIDO": CriterioPontuacao( codigo="CONS_FALECIDO", @@ -124,7 +122,6 @@ CRITERIOS: Dict[str, CriterioPontuacao] = { pontua_tempo=True, multiplicador_tempo=5, teto_tempo=50, - bonus_continuidade_8anos=20, ), "INSC_AUTOR": CriterioPontuacao( codigo="INSC_AUTOR", @@ -154,7 +151,7 @@ CRITERIOS: Dict[str, CriterioPontuacao] = { bloco=Bloco.D, tipo=TipoAtuacao.PAPEL, base=50, - teto=80, + teto=100, bonus_recorrencia_anual=3, teto_recorrencia=20, ), @@ -187,9 +184,23 @@ CRITERIOS: Dict[str, CriterioPontuacao] = { codigo="BOL_BPQ_INTERMEDIARIO", bloco=Bloco.D, tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO, + base=50, + teto=100, + ), + "BOL_BPQ_SUP": CriterioPontuacao( + codigo="BOL_BPQ_SUP", + bloco=Bloco.D, + tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO, base=30, teto=60, ), + "BOL_BPQ_INT": CriterioPontuacao( + codigo="BOL_BPQ_INT", + bloco=Bloco.D, + tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO, + base=50, + teto=100, + ), "PREMIACAO": CriterioPontuacao( codigo="PREMIACAO", bloco=Bloco.D, @@ -201,14 +212,14 @@ CRITERIOS: Dict[str, CriterioPontuacao] = { codigo="PREMIACAO_GP", bloco=Bloco.D, tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO, - base=50, + base=30, teto=60, ), "MENCAO": CriterioPontuacao( codigo="MENCAO", bloco=Bloco.D, tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO, - base=30, + base=10, teto=20, ), "EVENTO": CriterioPontuacao( @@ -230,63 +241,63 @@ CRITERIOS: Dict[str, CriterioPontuacao] = { bloco=Bloco.D, tipo=TipoAtuacao.PARTICIPACAO, base=15, - teto=0, + teto=100, ), "ORIENT_TESE": CriterioPontuacao( codigo="ORIENT_TESE", bloco=Bloco.D, tipo=TipoAtuacao.PARTICIPACAO, base=10, - teto=0, + teto=50, ), "ORIENT_DISS": CriterioPontuacao( codigo="ORIENT_DISS", bloco=Bloco.D, tipo=TipoAtuacao.PARTICIPACAO, base=5, - teto=0, + teto=25, ), "CO_ORIENT_POS_DOC": CriterioPontuacao( codigo="CO_ORIENT_POS_DOC", bloco=Bloco.D, tipo=TipoAtuacao.PARTICIPACAO, base=7, - teto=0, + teto=35, ), "CO_ORIENT_TESE": CriterioPontuacao( codigo="CO_ORIENT_TESE", bloco=Bloco.D, tipo=TipoAtuacao.PARTICIPACAO, base=5, - teto=0, + teto=25, ), "CO_ORIENT_DISS": CriterioPontuacao( codigo="CO_ORIENT_DISS", bloco=Bloco.D, tipo=TipoAtuacao.PARTICIPACAO, base=3, - teto=0, + teto=15, ), "MB_BANCA_POS_DOC": CriterioPontuacao( codigo="MB_BANCA_POS_DOC", bloco=Bloco.D, tipo=TipoAtuacao.PARTICIPACAO, base=3, - teto=0, + teto=15, ), "MB_BANCA_TESE": CriterioPontuacao( codigo="MB_BANCA_TESE", bloco=Bloco.D, tipo=TipoAtuacao.PARTICIPACAO, base=3, - teto=0, + teto=15, ), "MB_BANCA_DISS": CriterioPontuacao( codigo="MB_BANCA_DISS", bloco=Bloco.D, tipo=TipoAtuacao.PARTICIPACAO, base=2, - teto=0, + teto=10, ), } diff --git a/backend/src/infrastructure/elasticsearch/client.py b/backend/src/infrastructure/elasticsearch/client.py index d45b1b6..09e1fa5 100644 --- a/backend/src/infrastructure/elasticsearch/client.py +++ b/backend/src/infrastructure/elasticsearch/client.py @@ -3,11 +3,19 @@ from typing import Optional, Dict, Any class ElasticsearchClient: - def __init__(self, url: str, index: str, user: str = "", password: str = ""): + def __init__( + self, + url: str, + index: str, + user: str = "", + password: str = "", + verify_ssl: bool = True, + ): self.url = url.rstrip("/") self.index = index self.user = user self.password = password + self.verify_ssl = verify_ssl self._client: Optional[httpx.AsyncClient] = None async def connect(self) -> None: @@ -21,7 +29,7 @@ class ElasticsearchClient: "Content-Type": "application/json", "Accept": "application/json" }, - verify=False, + verify=self.verify_ssl, timeout=120.0 ) diff --git a/backend/src/infrastructure/ranking_store.py b/backend/src/infrastructure/ranking_store.py new file mode 100644 index 0000000..4ca3e66 --- /dev/null +++ b/backend/src/infrastructure/ranking_store.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + + +@dataclass(frozen=True) +class RankingEntry: + id_pessoa: int + nome: str + posicao: int + pontuacao_total: int + bloco_a: int + bloco_b: int + bloco_c: int + bloco_d: int + ativo: bool + anos_atuacao: float + detalhes: Dict[str, Any] + + +class RankingStore: + """ + Armazena o ranking pré-calculado em memória. + + Fonte única de dados: Elasticsearch (AtuaCAPES). + """ + + def __init__(self) -> None: + self._entries: List[RankingEntry] = [] + self._last_update: Optional[datetime] = None + self._lock = asyncio.Lock() + + @property + def last_update(self) -> Optional[datetime]: + return self._last_update + + def is_ready(self) -> bool: + return bool(self._entries) + + async def set_entries(self, entries: List[RankingEntry]) -> None: + async with self._lock: + self._entries = entries + self._last_update = datetime.now() + + def total(self, filtro_ativo: Optional[bool] = None) -> int: + if filtro_ativo is None: + return len(self._entries) + return sum(1 for e in self._entries if e.ativo == filtro_ativo) + + def get_page( + self, page: int, size: int, filtro_ativo: Optional[bool] = None + ) -> Tuple[int, List[RankingEntry]]: + if page < 1: + page = 1 + if size < 1: + size = 1 + + if filtro_ativo is None: + entries = self._entries + else: + entries = [e for e in self._entries if e.ativo == filtro_ativo] + + total = len(entries) + start = (page - 1) * size + end = start + size + return total, entries[start:end] + + def get_slice( + self, offset: int, limit: int, filtro_ativo: Optional[bool] = None + ) -> Tuple[int, List[RankingEntry]]: + if offset < 0: + offset = 0 + if limit < 1: + limit = 1 + + if filtro_ativo is None: + entries = self._entries + else: + entries = [e for e in self._entries if e.ativo == filtro_ativo] + + total = len(entries) + return total, entries[offset : offset + limit] + + def buscar_por_nome(self, nome: str, limit: int = 5) -> List[Dict[str, Any]]: + palavras = [p.strip() for p in (nome or "").upper().split() if len(p.strip()) >= 2] + if not palavras: + return [] + + results: List[Dict[str, Any]] = [] + for e in self._entries: + nome_e = (e.nome or "").upper() + if all(p in nome_e for p in palavras): + results.append( + { + "ID_PESSOA": e.id_pessoa, + "NOME": e.nome, + "POSICAO": e.posicao, + "PONTUACAO_TOTAL": float(e.pontuacao_total), + } + ) + if len(results) >= limit: + break + return results + + def get_by_id(self, id_pessoa: int) -> Optional[RankingEntry]: + for e in self._entries: + if e.id_pessoa == id_pessoa: + return e + return None + + +ranking_store = RankingStore() diff --git a/backend/src/infrastructure/repositories/consultor_repository_impl.py b/backend/src/infrastructure/repositories/consultor_repository_impl.py index 793749e..b189dfd 100644 --- a/backend/src/infrastructure/repositories/consultor_repository_impl.py +++ b/backend/src/infrastructure/repositories/consultor_repository_impl.py @@ -21,13 +21,12 @@ from ...domain.services.calculador_pontuacao import CalculadorPontuacao from ...domain.value_objects.periodo import Periodo, mesclar_periodos from ..cache import ranking_cache from ..elasticsearch.client import ElasticsearchClient -from ..oracle.client import OracleClient logger = logging.getLogger(__name__) class ConsultorRepositoryImpl(ConsultorRepository): - def __init__(self, es_client: ElasticsearchClient, oracle_client: OracleClient = None): + def __init__(self, es_client: ElasticsearchClient, oracle_client=None): self.es_client = es_client self.oracle_client = oracle_client self.calculador = CalculadorPontuacao() @@ -75,6 +74,8 @@ class ConsultorRepositoryImpl(ConsultorRepository): resultado = [] for coord in coordenacoes: dados_coord = coord.get("dadosCoordenacaoArea", {}) or {} + tipo_coord_raw = str(dados_coord.get("tipo", "") or coord.get("nome", "") or "") + presidente = "presidente" in tipo_coord_raw.lower() inicio = self._parse_date(dados_coord.get("inicioVinculacao")) or self._parse_date(coord.get("inicio")) if not inicio: continue @@ -97,10 +98,38 @@ class ConsultorRepositoryImpl(ConsultorRepository): periodo=Periodo(inicio=inicio, fim=fim), areas_adicionais=[], ja_coordenou_antes=len(resultado) > 0, + presidente=presidente, )) return resultado + @staticmethod + def _inferir_premiacao_tipo(texto: str) -> Optional[str]: + """ + Retorna o tipo de premiação em forma normalizada para uso em selos/hints. + """ + t = (texto or "").lower() + if "grande prêmio" in t or "grande premio" in t: + return "GP" + if "menção" in t or "mencao" in t or "honrosa" in t: + return "MENCAO" + if "prêmio" in t or "premio" in t: + return "PREMIO" + return None + + def _tem_coordenacao_ppg(self, atuacoes: List[Dict[str, Any]]) -> bool: + """ + Selo PPG_COORD: marca consultor que possui atuação de gestão/coordenação de programa no ATUACAPES. + A pontuação do PPG é reservada (V1), mas o selo é exibido. + """ + for a in atuacoes: + if a.get("dadosGestaoPrograma"): + return True + tipo = str(a.get("tipo", "") or "").lower() + if "programa" in tipo and ("coord" in tipo or "gest" in tipo): + return True + return False + def _extrair_consultoria(self, atuacoes: List[Dict[str, Any]]) -> Optional[Consultoria]: consultorias = [a for a in atuacoes if a.get("tipo") in ["Consultor", "Histórico de Consultoria"]] if not consultorias: @@ -248,17 +277,19 @@ class ConsultorRepositoryImpl(ConsultorRepository): dados = a.get("dadosPremiacaoPremio", {}) or a.get("dadosPremio", {}) or {} tipo_premiacao = dados.get("tipoPremiacao") or dados.get("premiacao") or "" nome_premio = dados.get("nomePremio") or dados.get("evento") or a.get("descricao", "") + papel = dados.get("papelPessoa") or dados.get("papel") or None ano = dados.get("ano") if not ano: inicio = self._parse_date(a.get("inicio")) ano = inicio.year if inicio else datetime.now().year - tipo_lower = tipo_premiacao.lower() - nome_lower = nome_premio.lower() + tipo_lower = str(tipo_premiacao).lower() + nome_lower = str(nome_premio).lower() + tipo_norm = self._inferir_premiacao_tipo(f"{tipo_lower} {nome_lower}") - if "grande" in nome_lower or "grande" in tipo_lower: + if tipo_norm == "GP": codigo = "PREMIACAO" - elif "menção" in tipo_lower or "mencao" in tipo_lower or "honrosa" in tipo_lower: + elif tipo_norm == "MENCAO": codigo = "MENCAO" else: codigo = "PREMIACAO_GP" @@ -268,6 +299,7 @@ class ConsultorRepositoryImpl(ConsultorRepository): tipo=tipo_premiacao, nome_premio=nome_premio, ano=ano, + papel=papel, )) return premiacoes @@ -283,10 +315,10 @@ class ConsultorRepositoryImpl(ConsultorRepository): area = dados.get("areaConhecimento", "") or "" nivel_lower = nivel.lower() - if "1a" in nivel_lower or "1b" in nivel_lower or "1c" in nivel_lower or "1d" in nivel_lower: - codigo = "BOL_BPQ_SUPERIOR" + if "1d" in nivel_lower or "2" in nivel_lower: + codigo = "BOL_BPQ_INT" else: - codigo = "BOL_BPQ_INTERMEDIARIO" + codigo = "BOL_BPQ_SUP" bolsas.append(BolsaCNPQ( codigo=codigo, @@ -331,6 +363,15 @@ class ConsultorRepositoryImpl(ConsultorRepository): dados = a.get("dadosOrientacao", {}) or {} nivel = dados.get("nivel", "") or dados.get("tipo", "") or "" + premio_texto = ( + dados.get("premiacao") + or dados.get("tipoPremiacao") + or dados.get("premio") + or dados.get("resultado") + or "" + ) + tipo_prem = self._inferir_premiacao_tipo(str(premio_texto)) + premiada = tipo_prem is not None ano = dados.get("ano") if not ano: inicio = self._parse_date(a.get("inicio")) @@ -349,6 +390,9 @@ class ConsultorRepositoryImpl(ConsultorRepository): tipo=tipo, nivel=nivel, ano=ano, + coorientacao=False, + premiada=premiada, + premiacao_tipo=tipo_prem, )) return orientacoes @@ -363,6 +407,15 @@ class ConsultorRepositoryImpl(ConsultorRepository): dados = a.get("dadosOrientacao", {}) or a.get("dadosCoorientacao", {}) or {} nivel = dados.get("nivel", "") or dados.get("tipo", "") or "" + premio_texto = ( + dados.get("premiacao") + or dados.get("tipoPremiacao") + or dados.get("premio") + or dados.get("resultado") + or "" + ) + tipo_prem = self._inferir_premiacao_tipo(str(premio_texto)) + premiada = tipo_prem is not None ano = dados.get("ano") if not ano: inicio = self._parse_date(a.get("inicio")) @@ -381,6 +434,9 @@ class ConsultorRepositoryImpl(ConsultorRepository): tipo=tipo, nivel=nivel, ano=ano, + coorientacao=True, + premiada=premiada, + premiacao_tipo=tipo_prem, )) return coorientacoes @@ -432,11 +488,13 @@ class ConsultorRepositoryImpl(ConsultorRepository): orientacoes = self._extrair_orientacoes(atuacoes) coorientacoes = self._extrair_coorientacoes(atuacoes) membros_banca = self._extrair_membros_banca(atuacoes) + coordenador_ppg = self._tem_coordenacao_ppg(atuacoes) consultor = Consultor( id_pessoa=id_pessoa, nome=dados_pessoais.get("nome", "N/A"), cpf=dados_pessoais.get("cpf"), + coordenador_ppg=coordenador_ppg, coordenacoes_capes=coordenacoes_capes, consultoria=consultoria, inscricoes=inscricoes, diff --git a/backend/src/interface/api/app.py b/backend/src/interface/api/app.py index e64edf4..959080e 100644 --- a/backend/src/interface/api/app.py +++ b/backend/src/interface/api/app.py @@ -8,13 +8,7 @@ from .routes import router logger = logging.getLogger(__name__) from .config import settings -from .dependencies import ( - es_client, - oracle_local_client, - oracle_remote_client, - get_processar_job, - get_popular_componente_b_job, -) +from .dependencies import es_client, get_processar_job from ...application.jobs.scheduler import RankingScheduler @@ -22,24 +16,12 @@ from ...application.jobs.scheduler import RankingScheduler async def lifespan(app: FastAPI): await es_client.connect() - try: - oracle_local_client.connect() - logger.info("Oracle LOCAL conectado (Docker)") - except Exception as e: - logger.warning(f"Oracle LOCAL não conectou: {e}") - - try: - oracle_remote_client.connect() - logger.info("Oracle REMOTO conectado (CAPES)") - except Exception as e: - logger.warning(f"Oracle REMOTO não conectou: {e}. Sistema rodando sem Componente B (PPG).") - scheduler = None try: - job = get_processar_job() - job_b = get_popular_componente_b_job() - scheduler = RankingScheduler(job, job_componente_b=job_b) - await scheduler.iniciar() + if settings.SCHEDULER_ENABLED: + job = get_processar_job() + scheduler = RankingScheduler(job) + await scheduler.iniciar(hora_alvo=settings.SCHEDULER_HOUR) except Exception as e: logger.warning(f"Scheduler não iniciou: {e}") @@ -53,16 +35,6 @@ async def lifespan(app: FastAPI): await es_client.close() - try: - oracle_local_client.close() - except: - pass - - try: - oracle_remote_client.close() - except: - pass - app = FastAPI( title="Ranking de Consultores CAPES", diff --git a/backend/src/interface/api/config.py b/backend/src/interface/api/config.py index 36374e0..19f1b95 100644 --- a/backend/src/interface/api/config.py +++ b/backend/src/interface/api/config.py @@ -10,16 +10,7 @@ class Settings(BaseSettings): ES_INDEX: str = "atuacapes" ES_USER: str = "" ES_PASSWORD: str = "" - - # Oracle LOCAL (Docker) - Para salvar ranking - ORACLE_LOCAL_USER: str - ORACLE_LOCAL_PASSWORD: str - ORACLE_LOCAL_DSN: str - - # Oracle REMOTO (CAPES) - Para ler dados de programas - ORACLE_REMOTE_USER: str - ORACLE_REMOTE_PASSWORD: str - ORACLE_REMOTE_DSN: str + ES_VERIFY_SSL: bool = True API_HOST: str = "0.0.0.0" API_PORT: int = 8000 @@ -28,6 +19,8 @@ class Settings(BaseSettings): CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173" LOG_LEVEL: str = "INFO" + SCHEDULER_ENABLED: bool = False + SCHEDULER_HOUR: int = 3 @property def cors_origins_list(self) -> List[str]: diff --git a/backend/src/interface/api/dependencies.py b/backend/src/interface/api/dependencies.py index 407b5e2..25e504e 100644 --- a/backend/src/interface/api/dependencies.py +++ b/backend/src/interface/api/dependencies.py @@ -1,9 +1,7 @@ from ...infrastructure.elasticsearch.client import ElasticsearchClient -from ...infrastructure.oracle.client import OracleClient -from ...infrastructure.oracle.ranking_repository import RankingOracleRepository from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl from ...application.jobs.processar_ranking import ProcessarRankingJob -from ...application.jobs.popular_componente_b_job import PopularComponenteBJob +from ...infrastructure.ranking_store import ranking_store, RankingStore from .config import settings @@ -11,41 +9,23 @@ es_client = ElasticsearchClient( url=settings.ES_URL, index=settings.ES_INDEX, user=settings.ES_USER, - password=settings.ES_PASSWORD -) - -# Oracle LOCAL (Docker) - Para salvar ranking -oracle_local_client = OracleClient( - user=settings.ORACLE_LOCAL_USER, - password=settings.ORACLE_LOCAL_PASSWORD, - dsn=settings.ORACLE_LOCAL_DSN -) - -# Oracle REMOTO (CAPES) - Para ler dados de programas -oracle_remote_client = OracleClient( - user=settings.ORACLE_REMOTE_USER, - password=settings.ORACLE_REMOTE_PASSWORD, - dsn=settings.ORACLE_REMOTE_DSN + password=settings.ES_PASSWORD, + verify_ssl=settings.ES_VERIFY_SSL, ) _repository: ConsultorRepositoryImpl = None -_ranking_repository: RankingOracleRepository = None _processar_job: ProcessarRankingJob = None -_popular_b_job: PopularComponenteBJob = None def get_repository() -> ConsultorRepositoryImpl: global _repository if _repository is None: - _repository = ConsultorRepositoryImpl(es_client=es_client, oracle_client=oracle_remote_client) + _repository = ConsultorRepositoryImpl(es_client=es_client, oracle_client=None) return _repository -def get_ranking_repository() -> RankingOracleRepository: - global _ranking_repository - if _ranking_repository is None: - _ranking_repository = RankingOracleRepository(oracle_client=oracle_local_client) - return _ranking_repository +def get_ranking_store() -> RankingStore: + return ranking_store def get_processar_job() -> ProcessarRankingJob: @@ -53,18 +33,6 @@ def get_processar_job() -> ProcessarRankingJob: if _processar_job is None: _processar_job = ProcessarRankingJob( es_client=es_client, - oracle_remote_client=oracle_remote_client, - oracle_local_client=oracle_local_client, - ranking_repo=get_ranking_repository() + ranking_store=ranking_store, ) return _processar_job - - -def get_popular_componente_b_job() -> PopularComponenteBJob: - global _popular_b_job - if _popular_b_job is None: - _popular_b_job = PopularComponenteBJob( - oracle_local_client=oracle_local_client, - oracle_remote_client=oracle_remote_client - ) - return _popular_b_job diff --git a/backend/src/interface/api/routes.py b/backend/src/interface/api/routes.py index e68a0be..f8b343c 100644 --- a/backend/src/interface/api/routes.py +++ b/backend/src/interface/api/routes.py @@ -1,9 +1,10 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks +import asyncio + +from fastapi import APIRouter, Depends, HTTPException, Query from typing import Optional, List from ...application.use_cases.obter_ranking import ObterRankingUseCase from ...application.use_cases.obter_consultor import ObterConsultorUseCase -from ...application.mappers import RankingMapper from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl from ..schemas.consultor_schema import ( RankingResponseSchema, @@ -20,7 +21,7 @@ from ..schemas.ranking_schema import ( ProcessarRankingResponseSchema, ConsultaNomeSchema, ) -from .dependencies import get_repository, get_ranking_repository, get_processar_job +from .dependencies import get_repository, get_ranking_store, get_processar_job from ...application.jobs.job_status import job_status router = APIRouter(prefix="/api/v1", tags=["ranking"]) @@ -34,19 +35,34 @@ async def obter_ranking( default=None, description="Filtrar por bloco (a, c, d)" ), repository: ConsultorRepositoryImpl = Depends(get_repository), + store = Depends(get_ranking_store), ): + if store.is_ready(): + total, entries = store.get_slice(offset=offset, limit=limite) + consultores_schema = [ + ConsultorResumoSchema( + id_pessoa=e.id_pessoa, + nome=e.nome, + anos_atuacao=e.anos_atuacao, + ativo=e.ativo, + veterano=e.anos_atuacao >= 10, + pontuacao_total=e.pontuacao_total, + bloco_a=e.bloco_a, + bloco_c=e.bloco_c, + bloco_d=e.bloco_d, + rank=e.posicao, + ) + for e in entries + ] + return RankingResponseSchema( + total=total, limite=limite, offset=offset, consultores=consultores_schema + ) + use_case = ObterRankingUseCase(repository=repository) consultores_dto = await use_case.executar(limite=limite, componente=componente) - total = await repository.contar_total() - - consultores_schema = [ - ConsultorResumoSchema(**vars(dto)) for dto in consultores_dto - ] - - return RankingResponseSchema( - total=total, limite=limite, offset=offset, consultores=consultores_schema - ) + consultores_schema = [ConsultorResumoSchema(**vars(dto)) for dto in consultores_dto] + return RankingResponseSchema(total=total, limite=limite, offset=offset, consultores=consultores_schema) @router.get("/ranking/detalhado", response_model=RankingDetalhadoResponseSchema) @@ -73,9 +89,15 @@ async def obter_ranking_detalhado( async def obter_consultor( id_pessoa: int, repository: ConsultorRepositoryImpl = Depends(get_repository), + store = Depends(get_ranking_store), ): use_case = ObterConsultorUseCase(repository=repository) - consultor = await use_case.executar(id_pessoa=id_pessoa) + rank = None + if store.is_ready(): + found = store.get_by_id(id_pessoa) + rank = found.posicao if found else None + + consultor = await use_case.executar(id_pessoa=id_pessoa, rank=rank) if not consultor: raise HTTPException(status_code=404, detail=f"Consultor {id_pessoa} não encontrado") @@ -93,14 +115,46 @@ async def ranking_paginado( page: int = Query(default=1, ge=1, description="Número da página"), size: int = Query(default=50, ge=1, le=1000, description="Tamanho da página (máx 1000)"), ativo: Optional[bool] = Query(default=None, description="Filtrar por status ativo"), - ranking_repo = Depends(get_ranking_repository), + store = Depends(get_ranking_store), ): - total = ranking_repo.contar_total(filtro_ativo=ativo) - consultores = ranking_repo.buscar_paginado(page=page, size=size, filtro_ativo=ativo) + if not store.is_ready(): + raise HTTPException( + status_code=503, + detail="Ranking ainda não foi processado. Execute POST /api/v1/ranking/processar.", + ) + + total, entries = store.get_page(page=page, size=size, filtro_ativo=ativo) total_pages = (total + size - 1) // size - consultores_schema = [RankingMapper.consultor_ranking_to_schema(c) for c in consultores] + consultores_schema = [] + for e in entries: + d = e.detalhes + consultores_schema.append( + ConsultorRankingResumoSchema( + id_pessoa=e.id_pessoa, + nome=e.nome, + posicao=e.posicao, + pontuacao_total=float(e.pontuacao_total), + bloco_a=float(e.bloco_a), + bloco_b=float(e.bloco_b), + bloco_c=float(e.bloco_c), + bloco_d=float(e.bloco_d), + ativo=e.ativo, + anos_atuacao=float(e.anos_atuacao), + coordenador_ppg=bool(d.get("coordenador_ppg", False)), + consultoria=d.get("consultoria"), + coordenacoes_capes=d.get("coordenacoes_capes"), + inscricoes=d.get("inscricoes"), + avaliacoes_comissao=d.get("avaliacoes_comissao"), + premiacoes=d.get("premiacoes"), + bolsas_cnpq=d.get("bolsas_cnpq"), + participacoes=d.get("participacoes"), + orientacoes=d.get("orientacoes"), + membros_banca=d.get("membros_banca"), + pontuacao=d.get("pontuacao"), + ) + ) return RankingPaginadoResponseSchema( total=total, @@ -115,9 +169,15 @@ async def ranking_paginado( async def buscar_por_nome( nome: str = Query(..., min_length=3, description="Nome (ou parte) para buscar"), limit: int = Query(default=5, ge=1, le=20, description="Limite de resultados"), - ranking_repo = Depends(get_ranking_repository), + store = Depends(get_ranking_store), ): - resultados = ranking_repo.buscar_por_nome(nome=nome, limit=limit) + if not store.is_ready(): + raise HTTPException( + status_code=503, + detail="Ranking ainda não foi processado. Execute POST /api/v1/ranking/processar.", + ) + + resultados = store.buscar_por_nome(nome=nome, limit=limit) return [ ConsultaNomeSchema( id_pessoa=r["ID_PESSOA"], @@ -131,10 +191,53 @@ async def buscar_por_nome( @router.get("/ranking/estatisticas", response_model=EstatisticasRankingSchema) async def ranking_estatisticas( - ranking_repo = Depends(get_ranking_repository), + store = Depends(get_ranking_store), ): - estatisticas = ranking_repo.obter_estatisticas() - distribuicao = ranking_repo.obter_distribuicao() + if not store.is_ready(): + raise HTTPException( + status_code=503, + detail="Ranking ainda não foi processado. Execute POST /api/v1/ranking/processar.", + ) + + total = store.total() + ativos = store.total(filtro_ativo=True) + inativos = total - ativos + entries = store.get_page(page=1, size=total)[1] if total else [] + totais = [e.pontuacao_total for e in entries] + distribuicao = [] + if total: + buckets = [ + ("800+", lambda x: x >= 800), + ("600-799", lambda x: 600 <= x < 800), + ("400-599", lambda x: 400 <= x < 600), + ("200-399", lambda x: 200 <= x < 400), + ("0-199", lambda x: x < 200), + ] + for faixa, pred in buckets: + qtd = sum(1 for x in totais if pred(x)) + distribuicao.append( + { + "faixa": faixa, + "quantidade": qtd, + "percentual": round((qtd * 100.0 / total), 2) if total else 0, + } + ) + + estatisticas = { + "total_consultores": total, + "total_ativos": ativos, + "total_inativos": inativos, + "ultima_atualizacao": store.last_update.isoformat() if store.last_update else None, + "pontuacao_media": (sum(totais) / total) if total else 0, + "pontuacao_maxima": max(totais) if totais else 0, + "pontuacao_minima": min(totais) if totais else 0, + "media_componentes": { + "a": (sum(e.bloco_a for e in entries) / total) if total else 0, + "b": (sum(e.bloco_b for e in entries) / total) if total else 0, + "c": (sum(e.bloco_c for e in entries) / total) if total else 0, + "d": (sum(e.bloco_d for e in entries) / total) if total else 0, + }, + } return EstatisticasRankingSchema( total_consultores=estatisticas.get("total_consultores", 0), @@ -156,14 +259,13 @@ async def status_processamento(): @router.post("/ranking/processar", response_model=ProcessarRankingResponseSchema) async def processar_ranking( - background_tasks: BackgroundTasks, request: ProcessarRankingRequestSchema = ProcessarRankingRequestSchema(), job = Depends(get_processar_job), ): if job_status.is_running: raise HTTPException(status_code=409, detail="Job já está em execução") - background_tasks.add_task(job.executar, limpar_antes=request.limpar_antes) + asyncio.create_task(job.executar(limpar_antes=request.limpar_antes)) return ProcessarRankingResponseSchema( sucesso=True, diff --git a/backend/src/interface/schemas/consultor_schema.py b/backend/src/interface/schemas/consultor_schema.py index 18177aa..87ec079 100644 --- a/backend/src/interface/schemas/consultor_schema.py +++ b/backend/src/interface/schemas/consultor_schema.py @@ -16,6 +16,7 @@ class CoordenacaoCapesSchema(BaseModel): periodo: PeriodoSchema areas_adicionais: List[str] ja_coordenou_antes: bool + presidente: bool = False class ConsultoriaSchema(BaseModel): @@ -48,6 +49,7 @@ class PremiacaoSchema(BaseModel): tipo: str nome_premio: str ano: int + papel: Optional[str] = None class BolsaCNPQSchema(BaseModel): @@ -68,6 +70,9 @@ class OrientacaoSchema(BaseModel): tipo: str nivel: str ano: Optional[int] = None + coorientacao: bool = False + premiada: bool = False + premiacao_tipo: Optional[str] = None class MembroBancaSchema(BaseModel): @@ -115,10 +120,10 @@ class ConsultorResumoSchema(BaseModel): class ConsultorDetalhadoSchema(BaseModel): id_pessoa: int nome: str - cpf: Optional[str] = None anos_atuacao: float ativo: bool veterano: bool + coordenador_ppg: bool = False coordenacoes_capes: List[CoordenacaoCapesSchema] consultoria: Optional[ConsultoriaSchema] = None inscricoes: List[InscricaoSchema] diff --git a/backend/src/interface/schemas/ranking_schema.py b/backend/src/interface/schemas/ranking_schema.py index 9d63d8c..2db43e4 100644 --- a/backend/src/interface/schemas/ranking_schema.py +++ b/backend/src/interface/schemas/ranking_schema.py @@ -14,6 +14,7 @@ class ConsultorRankingResumoSchema(BaseModel): bloco_d: float ativo: bool anos_atuacao: float + coordenador_ppg: Optional[bool] = None consultoria: Optional[dict] = None coordenacoes_capes: Optional[list] = None inscricoes: Optional[list] = None diff --git a/docker-compose.yml b/docker-compose.yml index 0106f60..92190c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,9 +22,6 @@ services: - /etc/localtime:/etc/localtime:ro networks: - shared_network - depends_on: - oracle18c: - condition: service_healthy restart: unless-stopped frontend: @@ -46,33 +43,7 @@ services: networks: - shared_network restart: unless-stopped - - oracle18c: - container_name: mqapilc_oracle18c - image: gvenzl/oracle-xe:18-slim - environment: - - ORACLE_PASSWORD=local123 - - ORACLE_CHARACTERSET=AL32UTF8 - - APP_USER=local123 - - APP_USER_PASSWORD=local123 - - TZ=America/Sao_Paulo - ports: - - "1521:1521" - - "5500:5500" - volumes: - - oracle_data:/opt/oracle/oradata - healthcheck: - test: ["CMD", "bash", "-c", "echo 'SELECT 1 FROM DUAL;' | sqlplus -s SYSTEM/\"$${ORACLE_PASSWORD}\"@localhost:1521/XEPDB1"] - interval: 30s - timeout: 10s - retries: 20 - networks: - - shared_network networks: shared_network: external: true - -volumes: - oracle_data: - driver: local diff --git a/scripts/reload_atuacapes.sh b/scripts/reload_atuacapes.sh index a381eb2..9f074df 100755 --- a/scripts/reload_atuacapes.sh +++ b/scripts/reload_atuacapes.sh @@ -15,7 +15,7 @@ for i in {1..30}; do if docker compose exec backend python - <<'PY' >/dev/null 2>&1; then import httpx, sys try: - r = httpx.get("http://localhost:8000/api/v1/health", verify=False, timeout=15) + r = httpx.get("http://localhost:8000/api/v1/health", timeout=15) if r.status_code == 200: sys.exit(0) except Exception: @@ -36,7 +36,7 @@ done echo "[4/5] Disparando job do ranking (limpar_antes=true)..." docker compose exec backend python - <<'PY' import httpx -client = httpx.Client(verify=False, timeout=120) +client = httpx.Client(timeout=120) resp = client.post("http://localhost:8000/api/v1/ranking/processar", json={"limpar_antes": True}) print("POST /api/v1/ranking/processar ->", resp.status_code, resp.text) PY @@ -44,7 +44,7 @@ PY echo "[5/5] Acompanhando status até finalizar..." docker compose exec backend python - <<'PY' import httpx, time -client = httpx.Client(verify=False, timeout=120) +client = httpx.Client(timeout=120) while True: r = client.get("http://localhost:8000/api/v1/ranking/status") data = r.json() diff --git a/start-ngrok.sh b/start-ngrok.sh index 6b13645..3e737d5 100755 --- a/start-ngrok.sh +++ b/start-ngrok.sh @@ -17,7 +17,7 @@ pkill -f "ngrok http 5173" 2>/dev/null echo "[4/5] Criando rede e subindo containers..." docker network create shared_network 2>/dev/null docker compose up -d backend frontend -echo "Aguardando backend subir (e Oracle ficar healthy)..." +echo "Aguardando backend subir..." sleep 10 docker compose up -d backend frontend >/dev/null @@ -33,9 +33,6 @@ if ! docker ps | grep -q ranking_frontend; then exit 1 fi -echo "[4b/5] Rodando bootstrap do ranking dentro do backend..." -docker exec ranking_backend bash -lc "ORACLE_LOCAL_DSN=oracle18c:1521/XEPDB1 /app/scripts/bootstrap_ranking.sh" - echo "[5/5] Iniciando ngrok..." ngrok http 5173 --log=stdout > /tmp/ngrok-ranking.log 2>&1 &