feat: extrair docencias PPG e simplificar blocos de pontuacao

Backend:
- Adicionar entidade DocenciaPPG para dados de docencia
- Extrair docencias do Elasticsearch (tipo "Docência")
- Serializar docencias no JSON de detalhes do consultor
- Aumentar batch size de 500 para 2000 para melhor performance

Frontend:
- Remover Bloco B (Coord. PPG) - reservado para V2
- Simplificar formula para: Bloco A + Bloco C + Bloco D
- Filtrar orientacoes/bancas da listagem (sao apenas selos)
- Atualizar Header com nota que PPG_COORD e apenas indicador
- Exibir pontuacao base nos modais de orientacao/banca
This commit is contained in:
Frederico Castro
2025-12-23 04:27:36 -03:00
parent d33695ed65
commit 8799a68c30
11 changed files with 154 additions and 123 deletions

View File

@@ -214,6 +214,23 @@ class ProcessarRankingJob:
}
for m in consultor.membros_banca
],
"docencias": [
{
"programa": d.programa,
"codigo_programa": d.codigo_programa,
"ies_sigla": d.ies_sigla,
"ies_nome": d.ies_nome,
"categoria": d.categoria,
"area_avaliacao": d.area_avaliacao,
"modalidade": d.modalidade,
"inicio": d.periodo.inicio.isoformat() if d.periodo.inicio else None,
"fim": d.periodo.fim.isoformat() if d.periodo.fim else None,
"ativo": d.periodo.ativo,
"carga_horaria": d.carga_horaria,
"linhas_pesquisa": d.linhas_pesquisa,
}
for d in consultor.docencias
],
"pontuacao": pontuacao,
}
@@ -289,7 +306,7 @@ class ProcessarRankingJob:
if limpar_antes:
self.ranking_oracle_repo.limpar_tabela()
batch_size = 500
batch_size = 2000
for i in range(0, len(consultores), batch_size):
batch = consultores[i:i + batch_size]
self.ranking_oracle_repo.inserir_batch(batch)

View File

@@ -53,6 +53,7 @@ class RankingMapper:
participacoes = None
orientacoes = None
membros_banca = None
docencias = None
pontuacao = None
tipos_atuacao = []
@@ -68,6 +69,7 @@ class RankingMapper:
participacoes = jd.get("participacoes")
orientacoes = jd.get("orientacoes")
membros_banca = jd.get("membros_banca")
docencias = jd.get("docencias")
pontuacao = jd.get("pontuacao")
tipos_atuacao = RankingMapper._extrair_tipos_atuacao(jd)
except (json.JSONDecodeError, TypeError) as e:
@@ -104,6 +106,7 @@ class RankingMapper:
participacoes=participacoes,
orientacoes=orientacoes,
membros_banca=membros_banca,
docencias=docencias,
pontuacao=pontuacao_ajustada if pontuacao_ajustada else None,
)

View File

@@ -105,6 +105,20 @@ class MembroBanca:
ano: Optional[int] = None
@dataclass
class DocenciaPPG:
programa: str
codigo_programa: str
ies_sigla: str
ies_nome: str
categoria: str
area_avaliacao: str
modalidade: str
periodo: Periodo
carga_horaria: Optional[int] = None
linhas_pesquisa: List[str] = field(default_factory=list)
@dataclass
class Consultor:
id_pessoa: int
@@ -120,6 +134,7 @@ class Consultor:
participacoes: List[Participacao] = field(default_factory=list)
orientacoes: List[Orientacao] = field(default_factory=list)
membros_banca: List[MembroBanca] = field(default_factory=list)
docencias: List[DocenciaPPG] = field(default_factory=list)
pontuacao: Optional[PontuacaoCompleta] = None
@property

View File

@@ -12,7 +12,7 @@ class RankingOracleRepository:
def inserir_batch(self, consultores: List[Dict[str, Any]]) -> int:
"""
Insere ou atualiza um batch de consultores usando MERGE.
Insere batch de consultores usando executemany (muito mais rápido).
Retorna o número de registros processados.
"""
import oracledb
@@ -20,68 +20,39 @@ class RankingOracleRepository:
if not consultores:
return 0
merge_sql = """
MERGE INTO TB_RANKING_CONSULTOR t
USING (
SELECT
:id_pessoa AS ID_PESSOA,
:nome AS NOME,
:pontuacao_total AS PONTUACAO_TOTAL,
:componente_a AS COMPONENTE_A,
:componente_b AS COMPONENTE_B,
:componente_c AS COMPONENTE_C,
:componente_d AS COMPONENTE_D,
:ativo AS ATIVO,
:anos_atuacao AS ANOS_ATUACAO,
TO_CLOB(:json_detalhes) AS JSON_DETALHES
FROM DUAL
) s
ON (t.ID_PESSOA = s.ID_PESSOA)
WHEN MATCHED THEN
UPDATE SET
t.NOME = s.NOME,
t.PONTUACAO_TOTAL = s.PONTUACAO_TOTAL,
t.COMPONENTE_A = s.COMPONENTE_A,
t.COMPONENTE_B = s.COMPONENTE_B,
t.COMPONENTE_C = s.COMPONENTE_C,
t.COMPONENTE_D = s.COMPONENTE_D,
t.ATIVO = s.ATIVO,
t.ANOS_ATUACAO = s.ANOS_ATUACAO,
t.DT_CALCULO = CURRENT_TIMESTAMP,
t.JSON_DETALHES = s.JSON_DETALHES
WHEN NOT MATCHED THEN
INSERT (
insert_sql = """
INSERT INTO TB_RANKING_CONSULTOR (
ID_PESSOA, NOME, PONTUACAO_TOTAL,
COMPONENTE_A, COMPONENTE_B, COMPONENTE_C, COMPONENTE_D,
ATIVO, ANOS_ATUACAO, JSON_DETALHES, DT_CALCULO
)
VALUES (
s.ID_PESSOA, s.NOME, s.PONTUACAO_TOTAL,
s.COMPONENTE_A, s.COMPONENTE_B, s.COMPONENTE_C, s.COMPONENTE_D,
s.ATIVO, s.ANOS_ATUACAO, s.JSON_DETALHES, CURRENT_TIMESTAMP
) VALUES (
:id_pessoa, :nome, :pontuacao_total,
:componente_a, :componente_b, :componente_c, :componente_d,
:ativo, :anos_atuacao, :json_detalhes, CURRENT_TIMESTAMP
)
"""
batch_data = []
for consultor in consultores:
json_str = json.dumps(consultor, ensure_ascii=False)
batch_data.append({
"id_pessoa": int(consultor["id_pessoa"]),
"nome": str(consultor.get("nome", ""))[:500],
"pontuacao_total": int(consultor.get("pontuacao_total") or 0),
"componente_a": int(consultor.get("bloco_a") or consultor.get("componente_a") or 0),
"componente_b": int(consultor.get("bloco_b") or consultor.get("componente_b") or 0),
"componente_c": int(consultor.get("bloco_c") or consultor.get("componente_c") or 0),
"componente_d": int(consultor.get("bloco_d") or consultor.get("componente_d") or 0),
"ativo": "S" if consultor.get("ativo") else "N",
"anos_atuacao": float(consultor.get("anos_atuacao") or 0),
"json_detalhes": json_str
})
with self.client.get_connection() as conn:
cursor = conn.cursor()
try:
for consultor in consultores:
json_str = json.dumps(consultor, ensure_ascii=False)
cursor.setinputsizes(json_detalhes=oracledb.DB_TYPE_CLOB)
params = {
"id_pessoa": consultor["id_pessoa"],
"nome": consultor["nome"],
"pontuacao_total": consultor["pontuacao_total"],
"componente_a": consultor.get("bloco_a") or consultor.get("componente_a", 0),
"componente_b": consultor.get("bloco_b") or consultor.get("componente_b", 0),
"componente_c": consultor.get("bloco_c") or consultor.get("componente_c", 0),
"componente_d": consultor.get("bloco_d") or consultor.get("componente_d", 0),
"ativo": "S" if consultor.get("ativo") else "N",
"anos_atuacao": consultor.get("anos_atuacao", 0),
"json_detalhes": json_str
}
cursor.execute(merge_sql, params)
cursor.executemany(insert_sql, batch_data)
conn.commit()
return len(consultores)
except Exception as e:
@@ -372,14 +343,18 @@ class RankingOracleRepository:
"""
Limpa todos os registros da tabela de ranking.
Usar apenas quando for reprocessar do zero.
TRUNCATE é muito mais rápido que DELETE para grandes volumes.
"""
with self.client.get_connection() as conn:
cursor = conn.cursor()
try:
cursor.execute("TRUNCATE TABLE TB_RANKING_CONSULTOR")
except Exception as e:
try:
cursor.execute("DELETE FROM TB_RANKING_CONSULTOR")
conn.commit()
except Exception as e:
except Exception as e2:
conn.rollback()
raise RuntimeError(f"Erro ao limpar tabela: {e}")
raise RuntimeError(f"Erro ao limpar tabela: {e2}")
finally:
cursor.close()

View File

@@ -17,6 +17,7 @@ from ...domain.entities.consultor import (
Participacao,
Orientacao,
MembroBanca,
DocenciaPPG,
)
from ...domain.repositories.consultor_repository import ConsultorRepository
from ...domain.services.calculador_pontuacao import CalculadorPontuacao
@@ -554,6 +555,51 @@ class ConsultorRepositoryImpl(ConsultorRepository):
return membros
def _extrair_docencias(self, atuacoes: List[Dict[str, Any]]) -> List[DocenciaPPG]:
docencias = []
for a in atuacoes:
if a.get("tipo") != "Docência":
continue
dados = a.get("dadosDocencia", {}) or {}
if not dados:
continue
programa_data = dados.get("programa", {}) or {}
ies_data = dados.get("ies", {}) or {}
area_data = dados.get("areaConhecimento", {}) or {}
area_aval_data = area_data.get("areaAvaliacao", {}) or {}
programa = programa_data.get("nome", "") or a.get("descricao", "")
codigo_programa = programa_data.get("codigo", "")
if not programa and not codigo_programa:
continue
inicio = self._parse_date(a.get("inicio"))
fim = self._parse_date(a.get("fim"))
linhas = []
for lp in dados.get("linhaPesquisa", []) or []:
if lp.get("nome"):
linhas.append(lp["nome"])
docencias.append(DocenciaPPG(
programa=programa,
codigo_programa=codigo_programa,
ies_sigla=ies_data.get("sigla", ""),
ies_nome=ies_data.get("nome", ""),
categoria=dados.get("categoria", ""),
area_avaliacao=area_aval_data.get("nome", "") or area_data.get("nome", ""),
modalidade=programa_data.get("modalidade", ""),
periodo=Periodo(inicio=inicio, fim=fim),
carga_horaria=dados.get("cargaHoraria"),
linhas_pesquisa=linhas[:3],
))
docencias.sort(key=lambda d: (d.periodo.fim is not None, d.periodo.inicio or datetime.min), reverse=True)
return docencias
async def _construir_consultor(self, doc: Dict[str, Any]) -> Consultor:
id_pessoa = doc["id"]
dados_pessoais = doc.get("dadosPessoais", {})
@@ -569,6 +615,7 @@ class ConsultorRepositoryImpl(ConsultorRepository):
orientacoes = self._extrair_orientacoes(atuacoes)
coorientacoes = self._extrair_coorientacoes(atuacoes)
membros_banca = self._extrair_membros_banca(atuacoes)
docencias = self._extrair_docencias(atuacoes)
coordenador_ppg = self._tem_coordenacao_ppg(atuacoes)
consultor = Consultor(
@@ -585,6 +632,7 @@ class ConsultorRepositoryImpl(ConsultorRepository):
participacoes=participacoes,
orientacoes=orientacoes + coorientacoes,
membros_banca=membros_banca,
docencias=docencias,
)
consultor.pontuacao = self.calculador.calcular_pontuacao_completa(consultor)

View File

@@ -253,6 +253,7 @@ async def ranking_paginado(
participacoes=d.get("participacoes"),
orientacoes=d.get("orientacoes"),
membros_banca=d.get("membros_banca"),
docencias=d.get("docencias"),
pontuacao=d.get("pontuacao"),
)
)

View File

@@ -25,6 +25,7 @@ class ConsultorRankingResumoSchema(BaseModel):
participacoes: Optional[list] = None
orientacoes: Optional[list] = None
membros_banca: Optional[list] = None
docencias: Optional[list] = None
pontuacao: Optional[dict] = None

View File

@@ -107,15 +107,13 @@ const CompararModal = ({ consultor1, consultor2, onClose }) => {
const blocoA1 = p1.bloco_a || { total: consultor1.bloco_a || 0 };
const blocoA2 = p2.bloco_a || { total: consultor2.bloco_a || 0 };
const blocoB1 = p1.bloco_b || { total: consultor1.bloco_b || 0 };
const blocoB2 = p2.bloco_b || { total: consultor2.bloco_b || 0 };
const blocoC1 = p1.bloco_c || { total: consultor1.bloco_c || 0 };
const blocoC2 = p2.bloco_c || { total: consultor2.bloco_c || 0 };
const blocoD1 = p1.bloco_d || { total: consultor1.bloco_d || 0 };
const blocoD2 = p2.bloco_d || { total: consultor2.bloco_d || 0 };
const total1 = (blocoA1.total || 0) + (blocoB1.total || 0) + (blocoC1.total || 0) + (blocoD1.total || 0);
const total2 = (blocoA2.total || 0) + (blocoB2.total || 0) + (blocoC2.total || 0) + (blocoD2.total || 0);
const total1 = Number(consultor1.pontuacao_total ?? 0);
const total2 = Number(consultor2.pontuacao_total ?? 0);
const c1 = consultor1.consultoria;
const c2 = consultor2.consultoria;
@@ -193,11 +191,6 @@ const CompararModal = ({ consultor1, consultor2, onClose }) => {
)}
</div>
<div className="comparacao-secao">
<h3 style={{ color: 'var(--accent)' }}>B - Coordenacao PPG</h3>
{renderLinhaComparacao('Total', blocoB1.total, blocoB2.total, 'var(--accent)')}
</div>
<div className="comparacao-secao">
<h3 style={{ color: 'var(--gold)' }}>C - Consultoria</h3>
{renderLinhaComparacao('Total', blocoC1.total, blocoC2.total, 'var(--gold)')}

View File

@@ -1010,6 +1010,12 @@
color: #f9a8d4;
}
.tipo-docente {
background: linear-gradient(135deg, rgba(20, 184, 166, 0.2), rgba(20, 184, 166, 0.08));
border-color: rgba(20, 184, 166, 0.35);
color: #5eead4;
}
.tipo-default {
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.25);

View File

@@ -818,8 +818,8 @@ const ItemDetalheModal = ({ item, tipo, onClose }) => {
</div>
)}
<div className="modal-detalhe-row">
<span className="modal-detalhe-label">Pontuação</span>
<span className="modal-detalhe-value muted">Apenas selo (sem pontuação)</span>
<span className="modal-detalhe-label">Pontuação Base</span>
<span className="modal-detalhe-value pontos">{PONTOS_BASE[item.codigo] || 0} pts</span>
</div>
</div>
);
@@ -853,17 +853,13 @@ const FORMULAS = {
titulo: 'Coordenacao CAPES',
descricao: 'CA=200 | CAJ=150 | CAJ_MP=120 | CAM=100\nTempo: multiplicador por ano (anos completos)\nBônus atualidade (mandato vigente) + Retorno (mandato anterior)',
},
bloco_b: {
titulo: 'Coordenacao PPG',
descricao: 'Reservado no V1: PPG_COORD base=0 | teto=0 (dados incompletos no ATUACAPES para pontuar).',
},
bloco_c: {
titulo: 'Consultoria',
descricao: 'CONS_ATIVO=150 | CONS_HIST=100 | CONS_FALECIDO=100\nTempo: 5 pts/ano (max 50)\nContinuidade: 3a=+5, 5a=+10, 8a+=+20 (escalonado)\nRetorno (reativação): +15 (uma vez)',
},
bloco_d: {
titulo: 'Premiacoes/Avaliacoes',
descricao: 'Premiações: GP=100 (teto 300) | Prêmio=50 (teto 150) | Menção=30 (teto 90)\nBolsas: BPQ=30 (teto 60)\nInscrições/Avaliações/Comissões/Participações (com tetos por código)\nOrientações/Bancas: apenas selos (0 pts)',
descricao: 'Premiações: GP=100 (teto 300) | Prêmio=50 (teto 150) | Menção=30 (teto 90)\nBolsas: BPQ=30 (teto 60)\nInscrições/Avaliações/Comissões/Participações (com tetos por código)\nOrientações e Bancas: apenas selos (sem pontuação)',
},
};
@@ -876,9 +872,6 @@ const PONTOS_BASE = {
PREMIACAO_GP_AUTOR: 100, PREMIACAO_AUTOR: 50, MENCAO_AUTOR: 30,
BOL_BPQ_NIVEL: 30,
EVENTO: 1, PROJ: 10,
ORIENT_POS_DOC: 0, ORIENT_TESE: 0, ORIENT_DISS: 0,
CO_ORIENT_POS_DOC: 0, CO_ORIENT_TESE: 0, CO_ORIENT_DISS: 0,
MB_BANCA_POS_DOC: 0, MB_BANCA_TESE: 0, MB_BANCA_DISS: 0,
};
const TETOS = {
@@ -894,15 +887,6 @@ const TETOS = {
EVENTO: { teto: 5, doc: '3.5 Participações Acadêmicas', bonus: '+1/participação (max 10)' },
PROJ: { teto: 30, doc: '3.5 Participações Acadêmicas', bonus: '+2/participação (max 10)' },
BOL_BPQ_NIVEL: { teto: 60, doc: '3.4 Premiações e Bolsas' },
ORIENT_POS_DOC: { teto: 0, doc: 'Selo (sem pontuação)' },
ORIENT_TESE: { teto: 0, doc: 'Selo (sem pontuação)' },
ORIENT_DISS: { teto: 0, doc: 'Selo (sem pontuação)' },
CO_ORIENT_POS_DOC: { teto: 0, doc: 'Selo (sem pontuação)' },
CO_ORIENT_TESE: { teto: 0, doc: 'Selo (sem pontuação)' },
CO_ORIENT_DISS: { teto: 0, doc: 'Selo (sem pontuação)' },
MB_BANCA_POS_DOC: { teto: 0, doc: 'Selo (sem pontuação)' },
MB_BANCA_TESE: { teto: 0, doc: 'Selo (sem pontuação)' },
MB_BANCA_DISS: { teto: 0, doc: 'Selo (sem pontuação)' },
};
const PontuacaoModal = ({ dados, onClose }) => {
@@ -1054,7 +1038,7 @@ const PontuacaoModal = ({ dados, onClose }) => {
<div className="modal-formula-section">
<span className="modal-detalhe-label">Fórmula</span>
<div className="modal-formula-box">
<div className="modal-formula-line">Bloco A + Bloco B + Bloco C + Bloco D</div>
<div className="modal-formula-line">Bloco A + Bloco C + Bloco D</div>
</div>
</div>
</div>
@@ -1132,10 +1116,9 @@ const ConsultorCard = memo(({ consultor, highlight, selecionado, onToggleSelecio
const { consultoria, pontuacao } = consultor;
const blocoA = pontuacao?.bloco_a || { total: consultor.bloco_a || 0 };
const blocoB = pontuacao?.bloco_b || { total: consultor.bloco_b || 0 };
const blocoC = pontuacao?.bloco_c || { total: consultor.bloco_c || 0 };
const blocoD = pontuacao?.bloco_d || { total: consultor.bloco_d || 0 };
const pontuacaoTotal = (blocoA.total || 0) + (blocoB.total || 0) + (blocoC.total || 0) + (blocoD.total || 0);
const pontuacaoTotal = Number(consultor.pontuacao_total ?? 0);
const selos = useMemo(() => gerarSelos(consultor), [consultor]);
@@ -1246,18 +1229,6 @@ const ConsultorCard = memo(({ consultor, highlight, selecionado, onToggleSelecio
bloco: blocoA
})}
/>
<ScoreItemClickable
value={blocoB.total}
label="BLOCO B"
style={{ color: blocoB.total > 0 ? 'var(--accent)' : 'var(--muted)' }}
onClick={() => setPontuacaoModal({
tipo: 'bloco',
label: 'BLOCO B - Coordenação PPG',
value: blocoB.total,
formula: FORMULAS.bloco_b.descricao,
bloco: blocoB
})}
/>
<ScoreItemClickable
value={blocoC.total}
label="BLOCO C"
@@ -1299,10 +1270,6 @@ const ConsultorCard = memo(({ consultor, highlight, selecionado, onToggleSelecio
<BlocoDetalhes titulo="A - Coordenacao CAPES" bloco={blocoA} cor="var(--accent-2)" onItemClick={setPontuacaoModal} />
)}
{(blocoB.total > 0 || (blocoB.atuacoes && blocoB.atuacoes.length > 0)) && (
<BlocoDetalhes titulo="B - Coordenacao PPG" bloco={blocoB} cor="var(--accent)" onItemClick={setPontuacaoModal} />
)}
{blocoC.atuacoes && blocoC.atuacoes.length > 0 && (
<BlocoDetalhes titulo="C - Consultoria" bloco={blocoC} cor="var(--gold)" onItemClick={setPontuacaoModal} />
)}
@@ -1571,11 +1538,23 @@ const ConsultorCard = memo(({ consultor, highlight, selecionado, onToggleSelecio
ConsultorCard.displayName = 'ConsultorCard';
const BlocoDetalhes = memo(({ titulo, bloco, cor, onItemClick }) => (
const CODIGOS_APENAS_SELO = [
'ORIENT_POS_DOC', 'ORIENT_TESE', 'ORIENT_DISS',
'CO_ORIENT_POS_DOC', 'CO_ORIENT_TESE', 'CO_ORIENT_DISS',
'MB_BANCA_POS_DOC', 'MB_BANCA_TESE', 'MB_BANCA_DISS',
'ORIENT_POS_DOC_PREM', 'ORIENT_TESE_PREM', 'ORIENT_DISS_PREM',
'CO_ORIENT_POS_DOC_PREM', 'CO_ORIENT_TESE_PREM', 'CO_ORIENT_DISS_PREM',
'MB_BANCA_POS_DOC_PREM', 'MB_BANCA_TESE_PREM', 'MB_BANCA_DISS_PREM',
];
const BlocoDetalhes = memo(({ titulo, bloco, cor, onItemClick }) => {
const atuacoesFiltradas = bloco.atuacoes?.filter(at => !CODIGOS_APENAS_SELO.includes(at.codigo)) || [];
return (
<div className="detail-section">
<h4 style={{ color: cor }}>{titulo}</h4>
<div className="score-breakdown">
{bloco.atuacoes?.map((at, idx) => (
{atuacoesFiltradas.map((at, idx) => (
<div
key={idx}
className="score-item-wrapper score-item-clicavel"
@@ -1608,7 +1587,8 @@ const BlocoDetalhes = memo(({ titulo, bloco, cor, onItemClick }) => (
</div>
</div>
</div>
));
);
});
BlocoDetalhes.displayName = 'BlocoDetalhes';

View File

@@ -64,17 +64,9 @@ const Header = ({ total }) => {
<div className="criteria-section bloco-e">
<div className="section-header">
<h4>E - Coord. PPG</h4>
<span className="max-pts">V2</span>
<span className="max-pts">indicador</span>
</div>
<table className="criteria-table">
<thead>
<tr><th>Cod</th><th>Base</th><th>Teto</th></tr>
</thead>
<tbody>
<tr><td>PPG_COORD</td><td>0</td><td>0</td></tr>
</tbody>
</table>
<div className="criteria-note">Dados incompletos no V1</div>
<div className="criteria-note">Nao entra no score (apenas selo/indicador no perfil)</div>
</div>
</div>