feat: Implementa job de ranking para 300k consultores
Backend: - Adiciona Scroll API no cliente Elasticsearch para processar todos os 300k+ consultores - Cria tabela TB_RANKING_CONSULTOR no Oracle para ranking pré-calculado - Implementa job de processamento com APScheduler (diário às 3h) - Adiciona endpoints: /ranking/paginado, /ranking/status, /ranking/processar, /ranking/estatisticas - Repository Oracle com paginação eficiente via ROW_NUMBER - Status do job com progresso em tempo real (polling) - Leitura automática de LOBs no OracleClient Frontend: - Componente RankingPaginado com paginação completa - Barra de progresso do job em tempo real - Botão para reprocessar ranking - Alternância entre Top N (rápido) e Ranking Completo (300k) Infraestrutura: - Docker compose com depends_on para garantir Oracle disponível - Schema SQL com procedure SP_ATUALIZAR_POSICOES - Índices otimizados para paginação
This commit is contained in:
0
backend/src/application/jobs/__init__.py
Normal file
0
backend/src/application/jobs/__init__.py
Normal file
87
backend/src/application/jobs/job_status.py
Normal file
87
backend/src/application/jobs/job_status.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobStatus:
|
||||
is_running: bool = False
|
||||
progress: int = 0
|
||||
total_processados: int = 0
|
||||
total_esperado: int = 0
|
||||
mensagem: str = ""
|
||||
inicio: Optional[datetime] = None
|
||||
fim: Optional[datetime] = None
|
||||
erro: Optional[str] = None
|
||||
batch_atual: int = 0
|
||||
total_batches: int = 0
|
||||
|
||||
def iniciar(self, total_esperado: int) -> None:
|
||||
self.is_running = True
|
||||
self.progress = 0
|
||||
self.total_processados = 0
|
||||
self.total_esperado = total_esperado
|
||||
self.mensagem = "Iniciando processamento..."
|
||||
self.inicio = datetime.now()
|
||||
self.fim = None
|
||||
self.erro = None
|
||||
self.batch_atual = 0
|
||||
self.total_batches = 0
|
||||
|
||||
def atualizar_progresso(self, processados: int, batch_atual: int, mensagem: str = "") -> None:
|
||||
self.total_processados = processados
|
||||
self.batch_atual = batch_atual
|
||||
if self.total_esperado > 0:
|
||||
self.progress = int((processados / self.total_esperado) * 100)
|
||||
self.mensagem = mensagem or f"Processando batch {batch_atual}"
|
||||
|
||||
def finalizar(self, sucesso: bool = True, erro: Optional[str] = None) -> None:
|
||||
self.is_running = False
|
||||
self.fim = datetime.now()
|
||||
self.progress = 100 if sucesso else self.progress
|
||||
self.erro = erro
|
||||
if sucesso:
|
||||
self.mensagem = f"Processamento concluído: {self.total_processados} consultores"
|
||||
else:
|
||||
self.mensagem = f"Processamento falhou: {erro}"
|
||||
|
||||
@property
|
||||
def tempo_decorrido(self) -> Optional[str]:
|
||||
if not self.inicio:
|
||||
return None
|
||||
fim = self.fim or datetime.now()
|
||||
delta = fim - self.inicio
|
||||
minutos, segundos = divmod(int(delta.total_seconds()), 60)
|
||||
return f"{minutos}m {segundos}s"
|
||||
|
||||
@property
|
||||
def tempo_estimado(self) -> Optional[str]:
|
||||
if not self.inicio or self.total_processados == 0 or self.total_esperado == 0:
|
||||
return None
|
||||
if self.progress >= 100:
|
||||
return "0m 0s"
|
||||
|
||||
decorrido = (datetime.now() - self.inicio).total_seconds()
|
||||
taxa = self.total_processados / decorrido
|
||||
restante = (self.total_esperado - self.total_processados) / taxa
|
||||
minutos, segundos = divmod(int(restante), 60)
|
||||
return f"{minutos}m {segundos}s"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"running": self.is_running,
|
||||
"progress": self.progress,
|
||||
"processados": self.total_processados,
|
||||
"total": self.total_esperado,
|
||||
"mensagem": self.mensagem,
|
||||
"batch_atual": self.batch_atual,
|
||||
"total_batches": self.total_batches,
|
||||
"tempo_decorrido": self.tempo_decorrido,
|
||||
"tempo_estimado": self.tempo_estimado,
|
||||
"inicio": self.inicio.isoformat() if self.inicio else None,
|
||||
"fim": self.fim.isoformat() if self.fim else None,
|
||||
"erro": self.erro
|
||||
}
|
||||
|
||||
|
||||
job_status = JobStatus()
|
||||
162
backend/src/application/jobs/processar_ranking.py
Normal file
162
backend/src/application/jobs/processar_ranking.py
Normal file
@@ -0,0 +1,162 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
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 ...domain.services.calculador_pontuacao import CalculadorPontuacao
|
||||
from .job_status import job_status
|
||||
|
||||
|
||||
class ProcessarRankingJob:
|
||||
def __init__(
|
||||
self,
|
||||
es_client: ElasticsearchClient,
|
||||
oracle_client: OracleClient,
|
||||
ranking_repo: RankingOracleRepository,
|
||||
):
|
||||
self.es_client = es_client
|
||||
self.oracle_client = oracle_client
|
||||
self.ranking_repo = ranking_repo
|
||||
self.consultor_repo = ConsultorRepositoryImpl(es_client, oracle_client)
|
||||
self.calculador = CalculadorPontuacao()
|
||||
|
||||
async def executar(self, limpar_antes: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
Executa o processamento completo do ranking:
|
||||
1. Limpa tabela (se solicitado)
|
||||
2. Scroll por todos os documentos ES
|
||||
3. Para cada batch: calcula pontuação e insere no Oracle
|
||||
4. Atualiza posições
|
||||
5. Retorna estatísticas
|
||||
"""
|
||||
if job_status.is_running:
|
||||
raise RuntimeError("Job já está em execução")
|
||||
|
||||
try:
|
||||
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..."
|
||||
|
||||
resultado = await self.es_client.buscar_todos_consultores(
|
||||
callback=self._processar_batch,
|
||||
batch_size=1000
|
||||
)
|
||||
|
||||
job_status.mensagem = "Atualizando posições no ranking..."
|
||||
self.ranking_repo.atualizar_posicoes()
|
||||
|
||||
estatisticas = self.ranking_repo.obter_estatisticas()
|
||||
|
||||
job_status.finalizar(sucesso=True)
|
||||
|
||||
return {
|
||||
"sucesso": True,
|
||||
"total_processados": resultado["processados"],
|
||||
"total_batches": resultado["batches"],
|
||||
"tempo_decorrido": job_status.tempo_decorrido,
|
||||
"estatisticas": estatisticas
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
job_status.finalizar(sucesso=False, erro=str(e))
|
||||
raise RuntimeError(f"Erro ao processar ranking: {e}")
|
||||
|
||||
async def _processar_batch(self, docs: list, progress: dict) -> None:
|
||||
"""
|
||||
Processa um batch de documentos:
|
||||
1. Constrói consultores
|
||||
2. Calcula pontuação
|
||||
3. Insere no Oracle
|
||||
4. Atualiza status
|
||||
"""
|
||||
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.componente_a.total,
|
||||
"componente_b": consultor.pontuacao.componente_b.total,
|
||||
"componente_c": consultor.pontuacao.componente_c.total,
|
||||
"componente_d": consultor.pontuacao.componente_d.total,
|
||||
"ativo": consultor.ativo,
|
||||
"anos_atuacao": consultor.anos_atuacao,
|
||||
"detalhes": self._gerar_json_detalhes(consultor)
|
||||
}
|
||||
|
||||
consultores_para_inserir.append(consultor_dict)
|
||||
|
||||
except Exception as e:
|
||||
print(f"AVISO: Erro ao processar consultor {doc.get('id')}: {e}")
|
||||
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"],
|
||||
mensagem=f"Processando batch {progress['batch_atual']} ({progress['percentual']}%)"
|
||||
)
|
||||
|
||||
def _gerar_json_detalhes(self, consultor) -> dict:
|
||||
"""
|
||||
Gera JSON com detalhes completos do consultor para armazenar no CLOB.
|
||||
"""
|
||||
return {
|
||||
"id_pessoa": consultor.id_pessoa,
|
||||
"nome": consultor.nome,
|
||||
"cpf": consultor.cpf,
|
||||
"coordenacoes_capes": [
|
||||
{
|
||||
"tipo": c.tipo,
|
||||
"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
|
||||
}
|
||||
for c in consultor.coordenacoes_capes
|
||||
],
|
||||
"coordenacoes_programas": [
|
||||
{
|
||||
"id_programa": c.id_programa,
|
||||
"nome_programa": c.nome_programa,
|
||||
"codigo_programa": c.codigo_programa,
|
||||
"nota_ppg": c.nota_ppg,
|
||||
"modalidade": c.modalidade,
|
||||
"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
|
||||
}
|
||||
for c in consultor.coordenacoes_programas
|
||||
],
|
||||
"consultoria": {
|
||||
"total_eventos": consultor.consultoria.total_eventos,
|
||||
"eventos_recentes": consultor.consultoria.eventos_recentes,
|
||||
"situacao": consultor.consultoria.situacao,
|
||||
"anos_completos": consultor.consultoria.anos_completos,
|
||||
"areas": consultor.consultoria.areas
|
||||
} if consultor.consultoria else None,
|
||||
"premiacoes": [
|
||||
{
|
||||
"tipo": p.tipo,
|
||||
"nome_premio": p.nome_premio,
|
||||
"ano": p.ano,
|
||||
"pontos": p.pontos
|
||||
}
|
||||
for p in consultor.premiacoes
|
||||
],
|
||||
"pontuacao": consultor.pontuacao.detalhamento
|
||||
}
|
||||
50
backend/src/application/jobs/scheduler.py
Normal file
50
backend/src/application/jobs/scheduler.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from typing import Optional
|
||||
|
||||
from .processar_ranking import ProcessarRankingJob
|
||||
|
||||
|
||||
class RankingScheduler:
|
||||
def __init__(self, job: ProcessarRankingJob):
|
||||
self.job = job
|
||||
self.scheduler: Optional[AsyncIOScheduler] = None
|
||||
|
||||
def iniciar(self) -> None:
|
||||
"""
|
||||
Inicia o scheduler e agenda o job para rodar diariamente às 3h.
|
||||
"""
|
||||
if self.scheduler and self.scheduler.running:
|
||||
return
|
||||
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
|
||||
self.scheduler.add_job(
|
||||
self.job.executar,
|
||||
trigger=CronTrigger(hour=3, minute=0),
|
||||
id='ranking_diario',
|
||||
name='Processamento diário do ranking de consultores',
|
||||
replace_existing=True,
|
||||
kwargs={"limpar_antes": True}
|
||||
)
|
||||
|
||||
self.scheduler.start()
|
||||
|
||||
def parar(self) -> None:
|
||||
"""
|
||||
Para o scheduler.
|
||||
"""
|
||||
if self.scheduler and self.scheduler.running:
|
||||
self.scheduler.shutdown(wait=False)
|
||||
|
||||
def executar_agora(self) -> None:
|
||||
"""
|
||||
Executa o job imediatamente (fora do agendamento).
|
||||
"""
|
||||
if self.scheduler:
|
||||
self.scheduler.add_job(
|
||||
self.job.executar,
|
||||
id='ranking_manual',
|
||||
replace_existing=True,
|
||||
kwargs={"limpar_antes": True}
|
||||
)
|
||||
Reference in New Issue
Block a user