feat: Sistema de Ranking de Consultores CAPES - versão inicial

Backend (FastAPI + DDD):
- Arquitetura DDD com camadas Domain, Application, Infrastructure, Interface
- Integração com Elasticsearch (ATUACAPES) para dados de consultores
- Integração com Oracle (SUCUPIRA_PAINEL) para coordenações PPG
- Cálculo dos 4 componentes de pontuação (A, B, C, D)
- Cache em memória para otimização de performance
- API REST com endpoints /ranking, /ranking/detalhado, /consultor/{id}

Frontend (React + Vite):
- Interface responsiva com cards expansíveis
- Visualização detalhada de pontuação por componente
- Filtro por quantidade de consultores (Top 10, 50, 100, etc)

Docker:
- docker-compose com shared_network externa
- Backend com Oracle Instant Client
- Frontend com Vite dev server
This commit is contained in:
Frederico Castro
2025-12-09 01:24:35 -03:00
commit 9e6ba459a8
69 changed files with 4902 additions and 0 deletions

View File

View File

View File

@@ -0,0 +1,77 @@
from dataclasses import dataclass, field
from typing import List, Optional
from datetime import datetime
from ..value_objects.periodo import Periodo
from ..value_objects.pontuacao import PontuacaoCompleta
@dataclass
class CoordenacaoCapes:
tipo: str
area_avaliacao: str
periodo: Periodo
areas_adicionais: List[str] = field(default_factory=list)
ja_coordenou_antes: bool = False
@dataclass
class CoordenacaoPrograma:
id_programa: int
nome_programa: str
codigo_programa: str
nota_ppg: str
modalidade: str
area_avaliacao: str
periodo: Periodo
@dataclass
class Consultoria:
total_eventos: int
eventos_recentes: int
primeiro_evento: datetime
ultimo_evento: datetime
vezes_responsavel: int
areas: List[str] = field(default_factory=list)
@dataclass
class Premiacao:
tipo: str
nome_premio: str
ano: int
pontos: int
@dataclass
class Consultor:
id_pessoa: int
nome: str
cpf: Optional[str] = None
coordenacoes_capes: List[CoordenacaoCapes] = field(default_factory=list)
coordenacoes_programas: List[CoordenacaoPrograma] = field(default_factory=list)
consultoria: Optional[Consultoria] = None
premiacoes: List[Premiacao] = field(default_factory=list)
pontuacao: Optional[PontuacaoCompleta] = None
@property
def anos_atuacao(self) -> float:
if not self.consultoria:
return 0.0
dias = (datetime.now() - self.consultoria.primeiro_evento).days
return round(dias / 365.25, 1)
@property
def ativo(self) -> bool:
if not self.consultoria:
return False
return self.consultoria.eventos_recentes > 0
@property
def veterano(self) -> bool:
return self.anos_atuacao >= 10.0
@property
def pontuacao_total(self) -> int:
return self.pontuacao.total if self.pontuacao else 0

View File

@@ -0,0 +1,26 @@
from abc import ABC, abstractmethod
from typing import List, Optional
from ..entities.consultor import Consultor
class ConsultorRepository(ABC):
@abstractmethod
async def buscar_por_id(self, id_pessoa: int) -> Optional[Consultor]:
pass
@abstractmethod
async def buscar_todos(
self, limite: int = 100, offset: int = 0, filtro_ativo: Optional[bool] = None
) -> List[Consultor]:
pass
@abstractmethod
async def buscar_ranking(
self, limite: int = 100, componente: Optional[str] = None
) -> List[Consultor]:
pass
@abstractmethod
async def contar_total(self, filtro_ativo: Optional[bool] = None) -> int:
pass

View File

@@ -0,0 +1,93 @@
from datetime import datetime, timedelta
from typing import List
from ..entities.consultor import (
Consultor,
CoordenacaoCapes,
CoordenacaoPrograma,
Consultoria,
Premiacao,
)
from ..value_objects.pontuacao import ComponentePontuacao, PontuacaoCompleta
class CalculadorPontuacao:
@staticmethod
def calcular_componente_a(coordenacoes: List[CoordenacaoCapes]) -> ComponentePontuacao:
if not coordenacoes:
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0, retorno=0)
coord_atual = next((c for c in coordenacoes if c.periodo.ativo), None)
if not coord_atual:
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0, retorno=0)
base_map = {"CA": 200, "CAJ": 150, "CAJ-MP": 120, "CAM": 100}
tempo_max_map = {"CA": 100, "CAJ": 80, "CAJ-MP": 60, "CAM": 50}
bonus_atual_map = {"CA": 30, "CAJ": 20, "CAJ-MP": 15, "CAM": 10}
base = base_map.get(coord_atual.tipo, 0)
anos = coord_atual.periodo.anos_decorridos
tempo = min(int(anos * 10), tempo_max_map.get(coord_atual.tipo, 0))
extras = min(len(coord_atual.areas_adicionais) * 20, 100)
bonus = bonus_atual_map.get(coord_atual.tipo, 0) if coord_atual.periodo.ativo else 0
retorno = 20 if coord_atual.ja_coordenou_antes else 0
return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=bonus, retorno=retorno)
@staticmethod
def calcular_componente_b(coordenacoes: List[CoordenacaoPrograma]) -> ComponentePontuacao:
if not coordenacoes:
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0)
base = 70
anos_totais = sum(c.periodo.anos_decorridos for c in coordenacoes)
tempo = min(int(anos_totais * 5), 50)
programas_distintos = len({c.id_programa for c in coordenacoes})
extras = min((programas_distintos - 1) * 20, 40)
coord_ativa = any(c.periodo.ativo for c in coordenacoes)
bonus = 20 if coord_ativa else 0
return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=bonus)
@staticmethod
def calcular_componente_c(consultoria: Consultoria) -> ComponentePontuacao:
if not consultoria:
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0)
base = 150 if consultoria.eventos_recentes > 0 else 100
anos = (datetime.now() - consultoria.primeiro_evento).days / 365.25
tempo = min(int(anos * 5), 50)
extras_eventos = min(consultoria.total_eventos * 2, 20)
extras_responsavel = min(consultoria.vezes_responsavel * 5, 25)
extras_areas = min((len(consultoria.areas) - 1) * 10, 30) if len(consultoria.areas) > 1 else 0
extras = extras_eventos + extras_responsavel + extras_areas
bonus = 0
return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=bonus)
@staticmethod
def calcular_componente_d(premiacoes: List[Premiacao]) -> ComponentePontuacao:
if not premiacoes:
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0)
total_pontos = sum(p.pontos for p in premiacoes)
total_pontos = min(total_pontos, 180)
return ComponentePontuacao(base=total_pontos, tempo=0, extras=0, bonus=0)
@classmethod
def calcular_pontuacao_completa(cls, consultor: Consultor) -> PontuacaoCompleta:
comp_a = cls.calcular_componente_a(consultor.coordenacoes_capes)
comp_b = cls.calcular_componente_b(consultor.coordenacoes_programas)
comp_c = cls.calcular_componente_c(consultor.consultoria)
comp_d = cls.calcular_componente_d(consultor.premiacoes)
return PontuacaoCompleta(
componente_a=comp_a, componente_b=comp_b, componente_c=comp_c, componente_d=comp_d
)

View File

@@ -0,0 +1,23 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass(frozen=True)
class Periodo:
inicio: datetime
fim: Optional[datetime] = None
@property
def ativo(self) -> bool:
return self.fim is None
@property
def anos_decorridos(self) -> float:
fim = self.fim if self.fim else datetime.now()
dias = (fim - self.inicio).days
return round(dias / 365.25, 1)
def __post_init__(self) -> None:
if self.fim and self.fim < self.inicio:
raise ValueError("Data de fim não pode ser anterior à data de início")

View File

@@ -0,0 +1,67 @@
from dataclasses import dataclass
from typing import Dict
@dataclass(frozen=True)
class ComponentePontuacao:
base: int
tempo: int
extras: int
bonus: int
retorno: int = 0
@property
def total(self) -> int:
return self.base + self.tempo + self.extras + self.bonus + self.retorno
@dataclass(frozen=True)
class PontuacaoCompleta:
componente_a: ComponentePontuacao
componente_b: ComponentePontuacao
componente_c: ComponentePontuacao
componente_d: ComponentePontuacao
@property
def total(self) -> int:
return (
self.componente_a.total
+ self.componente_b.total
+ self.componente_c.total
+ self.componente_d.total
)
@property
def detalhamento(self) -> Dict[str, Dict[str, int]]:
return {
"componente_a": {
"base": self.componente_a.base,
"tempo": self.componente_a.tempo,
"extras": self.componente_a.extras,
"bonus": self.componente_a.bonus,
"retorno": self.componente_a.retorno,
"total": self.componente_a.total,
},
"componente_b": {
"base": self.componente_b.base,
"tempo": self.componente_b.tempo,
"extras": self.componente_b.extras,
"bonus": self.componente_b.bonus,
"total": self.componente_b.total,
},
"componente_c": {
"base": self.componente_c.base,
"tempo": self.componente_c.tempo,
"extras": self.componente_c.extras,
"bonus": self.componente_c.bonus,
"total": self.componente_c.total,
},
"componente_d": {
"base": self.componente_d.base,
"tempo": self.componente_d.tempo,
"extras": self.componente_d.extras,
"bonus": self.componente_d.bonus,
"total": self.componente_d.total,
},
"pontuacao_total": self.total,
}