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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
61
backend/src/interface/schemas/ranking_schema.py
Normal file
61
backend/src/interface/schemas/ranking_schema.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ConsultorRankingResumoSchema(BaseModel):
|
||||
id_pessoa: int
|
||||
nome: str
|
||||
posicao: Optional[int]
|
||||
pontuacao_total: float
|
||||
componente_a: float
|
||||
componente_b: float
|
||||
componente_c: float
|
||||
componente_d: float
|
||||
ativo: bool
|
||||
anos_atuacao: float
|
||||
|
||||
|
||||
class RankingPaginadoResponseSchema(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
size: int
|
||||
total_pages: int
|
||||
consultores: List[ConsultorRankingResumoSchema]
|
||||
|
||||
|
||||
class EstatisticasRankingSchema(BaseModel):
|
||||
total_consultores: int
|
||||
total_ativos: int
|
||||
total_inativos: int
|
||||
ultima_atualizacao: Optional[str]
|
||||
pontuacao_media: float
|
||||
pontuacao_maxima: float
|
||||
pontuacao_minima: float
|
||||
media_componentes: dict
|
||||
distribuicao: List[dict]
|
||||
|
||||
|
||||
class JobStatusSchema(BaseModel):
|
||||
running: bool
|
||||
progress: int
|
||||
processados: int
|
||||
total: int
|
||||
mensagem: str
|
||||
batch_atual: int
|
||||
total_batches: int
|
||||
tempo_decorrido: Optional[str]
|
||||
tempo_estimado: Optional[str]
|
||||
inicio: Optional[str]
|
||||
fim: Optional[str]
|
||||
erro: Optional[str]
|
||||
|
||||
|
||||
class ProcessarRankingRequestSchema(BaseModel):
|
||||
limpar_antes: bool = Field(default=True, description="Se deve limpar a tabela antes de processar")
|
||||
|
||||
|
||||
class ProcessarRankingResponseSchema(BaseModel):
|
||||
sucesso: bool
|
||||
mensagem: str
|
||||
job_id: Optional[str] = None
|
||||
Reference in New Issue
Block a user