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:
Frederico Castro
2025-12-10 01:33:00 -03:00
parent 0213a55791
commit 3ea6a4409e
19 changed files with 1596 additions and 20 deletions

View File

@@ -4,7 +4,8 @@ from contextlib import asynccontextmanager
from .routes import router
from .config import settings
from .dependencies import es_client, oracle_client
from .dependencies import es_client, oracle_client, get_processar_job
from ...application.jobs.scheduler import RankingScheduler
@asynccontextmanager
@@ -14,7 +15,24 @@ async def lifespan(app: FastAPI):
oracle_client.connect()
except Exception as e:
print(f"AVISO: Oracle não conectou: {e}. Sistema rodando sem Coordenação PPG.")
scheduler = None
try:
job = get_processar_job()
scheduler = RankingScheduler(job)
scheduler.iniciar()
print("Scheduler do ranking iniciado: job rodará diariamente às 3h")
except Exception as e:
print(f"AVISO: Scheduler não iniciou: {e}")
yield
if scheduler:
try:
scheduler.parar()
except:
pass
await es_client.close()
try:
oracle_client.close()

View File

@@ -1,6 +1,8 @@
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 .config import settings
@@ -16,6 +18,8 @@ oracle_client = OracleClient(
)
_repository: ConsultorRepositoryImpl = None
_ranking_repository: RankingOracleRepository = None
_processar_job: ProcessarRankingJob = None
def get_repository() -> ConsultorRepositoryImpl:
@@ -23,3 +27,21 @@ def get_repository() -> ConsultorRepositoryImpl:
if _repository is None:
_repository = ConsultorRepositoryImpl(es_client=es_client, oracle_client=oracle_client)
return _repository
def get_ranking_repository() -> RankingOracleRepository:
global _ranking_repository
if _ranking_repository is None:
_ranking_repository = RankingOracleRepository(oracle_client=oracle_client)
return _ranking_repository
def get_processar_job() -> ProcessarRankingJob:
global _processar_job
if _processar_job is None:
_processar_job = ProcessarRankingJob(
es_client=es_client,
oracle_client=oracle_client,
ranking_repo=get_ranking_repository()
)
return _processar_job

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from typing import Optional
from ...application.use_cases.obter_ranking import ObterRankingUseCase
@@ -10,7 +10,16 @@ from ..schemas.consultor_schema import (
ConsultorDetalhadoSchema,
ConsultorResumoSchema,
)
from .dependencies import get_repository
from ..schemas.ranking_schema import (
RankingPaginadoResponseSchema,
ConsultorRankingResumoSchema,
EstatisticasRankingSchema,
JobStatusSchema,
ProcessarRankingRequestSchema,
ProcessarRankingResponseSchema,
)
from .dependencies import get_repository, get_ranking_repository, get_processar_job
from ...application.jobs.job_status import job_status
router = APIRouter(prefix="/api/v1", tags=["ranking"])
@@ -75,3 +84,88 @@ async def obter_consultor(
@router.get("/health")
async def health_check():
return {"status": "ok", "message": "API Ranking CAPES funcionando"}
@router.get("/ranking/paginado", response_model=RankingPaginadoResponseSchema)
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=100, description="Tamanho da página"),
ativo: Optional[bool] = Query(default=None, description="Filtrar por status ativo"),
ranking_repo = Depends(get_ranking_repository),
):
"""
Retorna ranking paginado do Oracle (pré-calculado).
"""
total = ranking_repo.contar_total(filtro_ativo=ativo)
consultores = ranking_repo.buscar_paginado(page=page, size=size, filtro_ativo=ativo)
total_pages = (total + size - 1) // size
consultores_schema = [
ConsultorRankingResumoSchema(
id_pessoa=c.id_pessoa,
nome=c.nome,
posicao=c.posicao,
pontuacao_total=c.pontuacao_total,
componente_a=c.componente_a,
componente_b=c.componente_b,
componente_c=c.componente_c,
componente_d=c.componente_d,
ativo=c.ativo,
anos_atuacao=c.anos_atuacao
)
for c in consultores
]
return RankingPaginadoResponseSchema(
total=total,
page=page,
size=size,
total_pages=total_pages,
consultores=consultores_schema
)
@router.get("/ranking/estatisticas", response_model=EstatisticasRankingSchema)
async def ranking_estatisticas(
ranking_repo = Depends(get_ranking_repository),
):
"""
Retorna estatísticas do ranking.
"""
estatisticas = ranking_repo.obter_estatisticas()
distribuicao = ranking_repo.obter_distribuicao()
return EstatisticasRankingSchema(
**estatisticas,
distribuicao=distribuicao
)
@router.get("/ranking/status", response_model=JobStatusSchema)
async def status_processamento():
"""
Retorna o status do job de processamento do ranking.
"""
return JobStatusSchema(**job_status.to_dict())
@router.post("/ranking/processar", response_model=ProcessarRankingResponseSchema)
async def processar_ranking(
background_tasks: BackgroundTasks,
request: ProcessarRankingRequestSchema = ProcessarRankingRequestSchema(),
job = Depends(get_processar_job),
):
"""
Dispara o processamento do ranking em background.
"""
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)
return ProcessarRankingResponseSchema(
sucesso=True,
mensagem="Processamento do ranking iniciado em background",
job_id="ranking_job"
)