From ff4d838f34a109cf44c08ec1e2f3468175b83165 Mon Sep 17 00:00:00 2001 From: Frederico Castro Date: Tue, 9 Dec 2025 22:57:46 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Aprimora=20c=C3=A1lculo=20de=20pontua?= =?UTF-8?q?=C3=A7=C3=A3o=20e=20extra=C3=A7=C3=A3o=20de=20dados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adiciona campos situacao, anos_completos, anos_consecutivos e retornos na entidade Consultoria para suportar regras documentadas - Implementa mesclagem de períodos sobrepostos para evitar contagem dupla - Melhora componente A com cálculo por área e detecção de retornos - Ajusta componente B com bônus por nota PPG - Refatora componente C com bônus de continuidade e retorno - Implementa componente D com classificação de nível de prêmio (Grande Prêmio, PCT, Interfarma, outros) e pontuação diferenciada - Trata datas inconsistentes (fim < início) como períodos em aberto - Extrai situacaoConsultoria do campo dadosConsultoria.situacaoConsultoria --- backend/src/application/dtos/consultor_dto.py | 4 + .../application/use_cases/obter_ranking.py | 4 + backend/src/domain/entities/consultor.py | 9 + .../domain/services/calculador_pontuacao.py | 107 +++++-- backend/src/domain/value_objects/periodo.py | 13 +- .../repositories/consultor_repository_impl.py | 290 ++++++++++++++---- .../src/interface/schemas/consultor_schema.py | 4 + 7 files changed, 348 insertions(+), 83 deletions(-) diff --git a/backend/src/application/dtos/consultor_dto.py b/backend/src/application/dtos/consultor_dto.py index 609089d..9a09fb9 100644 --- a/backend/src/application/dtos/consultor_dto.py +++ b/backend/src/application/dtos/consultor_dto.py @@ -39,6 +39,10 @@ class ConsultoriaDTO: ultimo_evento: str vezes_responsavel: int areas: List[str] + situacao: str + anos_completos: int + anos_consecutivos: int + retornos: int @dataclass diff --git a/backend/src/application/use_cases/obter_ranking.py b/backend/src/application/use_cases/obter_ranking.py index f0b69f1..ef5b504 100644 --- a/backend/src/application/use_cases/obter_ranking.py +++ b/backend/src/application/use_cases/obter_ranking.py @@ -94,6 +94,10 @@ class ObterRankingUseCase: ultimo_evento=consultor.consultoria.ultimo_evento.isoformat(), vezes_responsavel=consultor.consultoria.vezes_responsavel, areas=consultor.consultoria.areas, + situacao=consultor.consultoria.situacao, + anos_completos=consultor.consultoria.anos_completos, + anos_consecutivos=consultor.consultoria.anos_consecutivos, + retornos=consultor.consultoria.retornos, ) if consultor.consultoria else None, diff --git a/backend/src/domain/entities/consultor.py b/backend/src/domain/entities/consultor.py index e246efe..1d50065 100644 --- a/backend/src/domain/entities/consultor.py +++ b/backend/src/domain/entities/consultor.py @@ -34,6 +34,10 @@ class Consultoria: ultimo_evento: datetime vezes_responsavel: int areas: List[str] = field(default_factory=list) + situacao: str = "N/A" + anos_completos: int = 0 + anos_consecutivos: int = 0 + retornos: int = 0 @dataclass @@ -66,6 +70,11 @@ class Consultor: def ativo(self) -> bool: if not self.consultoria: return False + situacao = (self.consultoria.situacao or "").lower() + if "atividade" in situacao: + return True + if "inativ" in situacao: + return False return self.consultoria.eventos_recentes > 0 @property diff --git a/backend/src/domain/services/calculador_pontuacao.py b/backend/src/domain/services/calculador_pontuacao.py index 7be6e68..ef82bfd 100644 --- a/backend/src/domain/services/calculador_pontuacao.py +++ b/backend/src/domain/services/calculador_pontuacao.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime from typing import List from ..entities.consultor import ( @@ -9,9 +9,43 @@ from ..entities.consultor import ( Premiacao, ) from ..value_objects.pontuacao import ComponentePontuacao, PontuacaoCompleta +from ..value_objects.periodo import Periodo class CalculadorPontuacao: + @staticmethod + def _mesclar_periodos(periodos: List[Periodo]) -> List[Periodo]: + """ + Mescla períodos sobrepostos/contíguos para evitar contagem dupla. + """ + if not periodos: + return [] + + periodos_ordenados = sorted(periodos, key=lambda p: p.inicio) + mesclados: List[Periodo] = [] + + for p in periodos_ordenados: + if not mesclados: + mesclados.append(p) + continue + + ultimo = mesclados[-1] + ultimo_fim = ultimo.fim or datetime.now() + atual_fim = p.fim or datetime.now() + + if p.inicio <= ultimo_fim: + novo_fim = max(ultimo_fim, atual_fim) + mesclados[-1] = Periodo(inicio=ultimo.inicio, fim=novo_fim if not ultimo.ativo else None) + else: + mesclados.append(p) + + return mesclados + + @staticmethod + def _anos_completos_periodos(periodos: List[Periodo]) -> int: + ref = datetime.now() + return sum(p.anos_completos(ref) for p in periodos) + @staticmethod def calcular_componente_a(coordenacoes: List[CoordenacaoCapes]) -> ComponentePontuacao: if not coordenacoes: @@ -38,19 +72,32 @@ class CalculadorPontuacao: return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0, retorno=0) coord_do_tipo = coord_por_tipo.get(coord_escolhida_tipo, []) - anos_total = sum(c.periodo.anos_decorridos for c in coord_do_tipo) - ativo = any(c.periodo.ativo for c in coord_do_tipo) + coord_por_area = {} + for c in coord_do_tipo: + area = c.area_avaliacao or "N/A" + coord_por_area.setdefault(area, []).append(c.periodo) + + anos_total = 0 + ativo = False + retornos_encontrados = 0 + + for _, periodos_area in coord_por_area.items(): + mesclados = CalculadorPontuacao._mesclar_periodos(periodos_area) + anos_total += CalculadorPontuacao._anos_completos_periodos(mesclados) + ativo = ativo or any(p.ativo for p in periodos_area) + if len(mesclados) > 1: + retornos_encontrados += 1 base = base_map.get(coord_escolhida_tipo, 0) tempo = min(int(anos_total * mult_tempo_map.get(coord_escolhida_tipo, 0)), tempo_max_map.get(coord_escolhida_tipo, 0)) extras = 0 - areas_adicionais = [a for c in coord_do_tipo for a in c.areas_adicionais] - if areas_adicionais: - extras = min(len(set(areas_adicionais)) * 20, 100) + areas_distintas = list(coord_por_area.keys()) + if len(areas_distintas) > 1: + extras = min((len(areas_distintas) - 1) * 20, 100) bonus = bonus_atual_map.get(coord_escolhida_tipo, 0) if ativo else 0 - retorno = 20 if len(coord_do_tipo) > 1 else 0 + retorno = 20 if retornos_encontrados > 0 else 0 return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=bonus, retorno=retorno) @@ -60,33 +107,51 @@ class CalculadorPontuacao: return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0) base = 70 - anos_totais = sum(c.periodo.anos_decorridos for c in coordenacoes) + anos_totais = sum(c.periodo.anos_completos(datetime.now()) 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 + nota_bonus = 0 + for c in coordenacoes: + try: + nota_num = float(c.nota_ppg) + if nota_num >= 0: + nota_bonus = 20 + break + except Exception: + continue - return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=bonus) + return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=nota_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 + situacao = (consultoria.situacao or "").lower() + base = 150 if "atividade" in situacao else 100 - anos = (datetime.now() - consultoria.primeiro_evento).days / 365.25 + anos = consultoria.anos_completos if consultoria.anos_completos else int( + ((datetime.now() - consultoria.primeiro_evento).days) // 365 + ) 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 + continuidade = consultoria.anos_consecutivos + if continuidade >= 8: + bonus_continuidade = 15 + elif continuidade >= 5: + bonus_continuidade = 10 + elif continuidade >= 3: + bonus_continuidade = 5 + else: + bonus_continuidade = 0 - bonus = 0 + retorno_bonus = 15 if consultoria.retornos > 0 else 0 + extras = 0 + + bonus = bonus_continuidade + retorno_bonus return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=bonus) @@ -95,7 +160,11 @@ class CalculadorPontuacao: if not premiacoes: return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0) - total_pontos = sum(p.pontos for p in premiacoes) + avaliador = [p for p in premiacoes if "avaliador" in (p.tipo or "").lower()] + outros = [p for p in premiacoes if p not in avaliador] + + pontos_avaliador = min(sum(p.pontos for p in avaliador), 20) + total_pontos = pontos_avaliador + sum(p.pontos for p in outros) total_pontos = min(total_pontos, 180) return ComponentePontuacao(base=total_pontos, tempo=0, extras=0, bonus=0) diff --git a/backend/src/domain/value_objects/periodo.py b/backend/src/domain/value_objects/periodo.py index 1f18765..3c02d5a 100644 --- a/backend/src/domain/value_objects/periodo.py +++ b/backend/src/domain/value_objects/periodo.py @@ -18,6 +18,17 @@ class Periodo: dias = (fim - self.inicio).days return round(dias / 365.25, 1) + def anos_completos(self, data_referencia: Optional[datetime] = None) -> int: + """ + Retorna apenas anos completos entre início e fim (ou data de referência). + Usado para pontuação que desconsidera frações de ano. + """ + fim = self.fim or data_referencia or datetime.now() + if fim < self.inicio: + return 0 + return int((fim - self.inicio).days // 365) + def __post_init__(self) -> None: + # Se houver fim anterior ao início, o período é tratado como aberto. if self.fim and self.fim < self.inicio: - raise ValueError("Data de fim não pode ser anterior à data de início") + object.__setattr__(self, "fim", None) diff --git a/backend/src/infrastructure/repositories/consultor_repository_impl.py b/backend/src/infrastructure/repositories/consultor_repository_impl.py index e3be4ec..8b8d74c 100644 --- a/backend/src/infrastructure/repositories/consultor_repository_impl.py +++ b/backend/src/infrastructure/repositories/consultor_repository_impl.py @@ -48,6 +48,30 @@ class ConsultorRepositoryImpl(ConsultorRepository): self.calculador = CalculadorPontuacao() self.es_disponivel = True + def _mesclar_periodos(self, periodos: List[Periodo]) -> List[Periodo]: + """ + Mescla períodos sobrepostos/contíguos para evitar contagem dupla de tempo. + """ + if not periodos: + return [] + + periodos = sorted(periodos, key=lambda p: p.inicio) + mesclados: List[Periodo] = [] + for p in periodos: + if not mesclados: + mesclados.append(p) + continue + + ultimo = mesclados[-1] + fim_ultimo = ultimo.fim or datetime.now() + fim_atual = p.fim or datetime.now() + if p.inicio <= fim_ultimo: + novo_fim = max(fim_ultimo, fim_atual) + mesclados[-1] = Periodo(inicio=ultimo.inicio, fim=novo_fim if not ultimo.ativo else None) + else: + mesclados.append(p) + return mesclados + def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: if not date_str: return None @@ -63,22 +87,51 @@ class ConsultorRepositoryImpl(ConsultorRepository): if not consultorias: return None - datas_inicio_consultoria = [ - self._parse_date(c.get("inicio")) - for c in consultorias - ] - datas_inicio_consultoria = [d for d in datas_inicio_consultoria if d] + periodos: List[Periodo] = [] + situacoes: List[str] = [] + areas: List[str] = [] - if not datas_inicio_consultoria: + for c in consultorias: + dc = c.get("dadosConsultoria", {}) or {} + situacao = dc.get("situacaoConsultoria") or c.get("situacaoConsultoria") + if situacao: + situacoes.append(situacao) + + inicio = ( + self._parse_date(dc.get("inicioVinculacao")) + or self._parse_date(dc.get("inicioSituacao")) + or self._parse_date(c.get("inicio")) + ) + fim = ( + self._parse_date(dc.get("fimVinculacao")) + or self._parse_date(dc.get("inativacaoSituacao")) + or self._parse_date(c.get("fim")) + ) + + if inicio and fim and fim < inicio: + fim = None # dados inconsistentes: trata como em aberto + if inicio: + try: + periodos.append(Periodo(inicio=inicio, fim=fim)) + except ValueError: + continue + + area = dc.get("areaAvaliacao") or c.get("areaAvaliacao") + if area: + areas.append(area) + + periodos = [p for p in periodos if p.inicio] + if not periodos: return None - eventos_sae = [ - a for a in atuacoes if a.get("tipo") == "Evento" - ] + mesclados = self._mesclar_periodos(periodos) + anos_total = sum(p.anos_completos(datetime.now()) for p in mesclados) + anos_consecutivos = max((p.anos_completos(datetime.now()) for p in mesclados), default=0) + retornos = max(0, len(mesclados) - 1) + ativo = any(p.ativo for p in periodos) + eventos_sae = [a for a in atuacoes if a.get("tipo") == "Evento"] total_eventos = len(eventos_sae) - - # considerar últimos 24 meses como janela de atividade limite_recente = datetime.now() - timedelta(days=730) eventos_recentes = 0 for ev in eventos_sae: @@ -86,30 +139,25 @@ class ConsultorRepositoryImpl(ConsultorRepository): if data_fim and data_fim >= limite_recente: eventos_recentes += 1 - dados_consultoria = consultorias[0].get("dadosConsultoria", {}) or {} - areas = [] - for c in consultorias: - dc = c.get("dadosConsultoria", {}) or {} - area = dc.get("areaAvaliacao") or c.get("areaAvaliacao") - if area: - areas.append(area) - areas = list(set(areas)) if areas else ["N/A"] + primeiro_evento = min(p.inicio for p in periodos) + ultimo_evento = max((p.fim or datetime.now()) for p in periodos) if not ativo else datetime.now() + areas = list(set(areas)) if areas else ["N/A"] vezes_responsavel = sum(1 for c in consultorias if c.get("responsavel", False)) - datas_fim_consultoria = [ - self._parse_date(c.get("fim")) - for c in consultorias - ] - datas_fim_consultoria = [d for d in datas_fim_consultoria if d] + situacao_final = situacoes[0] if situacoes else "N/A" return Consultoria( total_eventos=total_eventos, eventos_recentes=eventos_recentes, - primeiro_evento=min(datas_inicio_consultoria), - ultimo_evento=max(datas_fim_consultoria) if datas_fim_consultoria else datetime.now(), + primeiro_evento=primeiro_evento, + ultimo_evento=ultimo_evento, vezes_responsavel=vezes_responsavel, areas=areas, + situacao=situacao_final, + anos_completos=anos_total, + anos_consecutivos=anos_consecutivos, + retornos=retornos, ) def _extrair_coordenacoes_capes( @@ -127,14 +175,24 @@ class ConsultorRepositoryImpl(ConsultorRepository): resultado = [] for coord in coordenacoes: - inicio = self._parse_date(coord.get("inicio")) + dados_coord = coord.get("dadosCoordenacaoArea", {}) or {} + + inicio = ( + self._parse_date(dados_coord.get("inicioVinculacao")) + or self._parse_date(coord.get("inicio")) + ) if not inicio: continue tipo = self._inferir_tipo_coordenacao(coord) - fim = self._parse_date(coord.get("fim")) + fim = ( + self._parse_date(dados_coord.get("fimVinculacao")) + or self._parse_date(coord.get("fim")) + ) + + if inicio and fim and fim < inicio: + fim = None # ignora fins inconsistentes para não quebrar cálculo - dados_coord = coord.get("dadosCoordenacaoArea", {}) or {} area_avaliacao_obj = dados_coord.get("areaAvaliacao", {}) or {} area_avaliacao = area_avaliacao_obj.get("nome") if isinstance(area_avaliacao_obj, dict) else coord.get("areaAvaliacao", "N/A") if not area_avaliacao: @@ -178,45 +236,151 @@ class ConsultorRepositoryImpl(ConsultorRepository): else: return "CA" + def _classificar_nivel_premio(self, nome: str) -> str: + nome = (nome or "").lower() + if "grande prêmio capes de tese" in nome or "grande premio capes de tese" in nome: + return "nivel1_grande" + if "prêmio capes de tese" in nome or "premio capes de tese" in nome: + return "nivel1_pct" + if "interfarma" in nome or "vale-capes" in nome or "vale capes" in nome: + return "nivel2" + if nome: + return "nivel3" + return "desconhecido" + + def _pontuar_premiacao_recebida(self, nivel: str, tipo_premiacao: str) -> int: + tipo = (tipo_premiacao or "").lower() + if nivel == "nivel1_grande": + base = 150 + extra = 50 if "grande" in tipo else 0 + return min(base + extra, 180) + if nivel == "nivel1_pct": + base = 100 + if "mencao" in tipo: + extra = 15 + elif "premio" in tipo: + extra = 25 + else: + extra = 0 + return min(base + extra, 150) + if nivel == "nivel2": + base = 30 + if "premio" in tipo: + extra = 20 + elif "mencao" in tipo: + extra = 10 + else: + extra = 0 + return min(base + extra, 60) + # nivel3 e fallback + base = 10 + if "premio" in tipo: + extra = 5 + elif "mencao" in tipo: + extra = 3 + else: + extra = 0 + return min(base + extra, 20) + + def _pontuar_participacao_premio(self, nivel: str, tipo_participacao: str) -> int: + tipo = (tipo_participacao or "").lower() + if "avaliador" in tipo: + return 2 # teto final tratado em componente D + if "coordenador" in tipo: + if nivel == "nivel1_grande": + return 115 # valor máximo já com peso + if nivel == "nivel1_pct": + return 115 # aproximação segura para teto + if nivel == "nivel2": + return 80 + return 40 + if "inscricao" in tipo or "inscrição" in tipo: + if nivel in ["nivel1_grande", "nivel1_pct"]: + return 2 + if nivel == "nivel2": + return 1 + return 1 + return 0 + def _extrair_premiacoes(self, atuacoes: List[Dict[str, Any]]) -> List[Premiacao]: - premiacoes_data = [ - a - for a in atuacoes - if a.get("tipo") - in [ - "Premiação Prêmio", - "Avaliação Prêmio", - "Inscrição Prêmio", - "Premiação", - ] - ] - premiacoes = [] - for prem in premiacoes_data: - pontos = self._calcular_pontos_premiacao(prem.get("tipo", "")) - inicio = self._parse_date(prem.get("inicio")) - ano = inicio.year if inicio else datetime.now().year - - premiacoes.append( - Premiacao( - tipo=prem.get("tipo", "N/A"), - nome_premio=prem.get("descricao", "N/A"), - ano=ano, - pontos=pontos, - ) + for a in atuacoes: + tipo_atuacao = a.get("tipo", "") + dados_premiacao = a.get("dadosPremiacaoPremio") or a.get("dadosPremio") or {} + dados_participacao = ( + a.get("dadosParticipacaoPremio") + or a.get("dadosParticipacaoInscricaoPremio") + or {} ) - return premiacoes + # Premiações recebidas + if dados_premiacao: + nome_premio = dados_premiacao.get("nomePremio") or a.get("descricao", "N/A") + tipo_premiacao = dados_premiacao.get("tipoPremiacao") or dados_premiacao.get("tipo", "") + ano = dados_premiacao.get("ano") or a.get("ano") + if not ano: + inicio = self._parse_date(a.get("inicio")) + ano = inicio.year if inicio else datetime.now().year - def _calcular_pontos_premiacao(self, tipo: str) -> int: - # Aproximação das regras (D) seguindo .claude/rules/ranking-consultores-capes.md - mapa = { - "Premiação Prêmio": 150, - "Premiação": 150, - "Avaliação Prêmio": 40, - "Inscrição Prêmio": 10, - } - return mapa.get(tipo, 0) + nivel = self._classificar_nivel_premio(nome_premio) + pontos = self._pontuar_premiacao_recebida(nivel, tipo_premiacao) + + premiacoes.append( + Premiacao( + tipo=tipo_premiacao or tipo_atuacao or "Premiação", + nome_premio=nome_premio, + ano=ano or datetime.now().year, + pontos=int(pontos), + ) + ) + continue + + # Participações (inscrição/avaliação/coordenação) + if dados_participacao: + tipo_part = ( + dados_participacao.get("tipoParticipacao") + or dados_participacao.get("tipo") + or tipo_atuacao + ) + nome_premio = dados_participacao.get("nomePremio") or a.get("descricao", "N/A") + ano = dados_participacao.get("ano") or a.get("ano") + if not ano: + inicio = self._parse_date(a.get("inicio")) + ano = inicio.year if inicio else datetime.now().year + + nivel = self._classificar_nivel_premio(nome_premio) + pontos = self._pontuar_participacao_premio(nivel, tipo_part) + + premiacoes.append( + Premiacao( + tipo=tipo_part or "Participação Prêmio", + nome_premio=nome_premio, + ano=ano or datetime.now().year, + pontos=int(pontos), + ) + ) + continue + + # Fallback para tipos antigos + if tipo_atuacao in [ + "Premiação Prêmio", + "Premiação", + "Avaliação Prêmio", + "Inscrição Prêmio", + ]: + pontos = self._pontuar_participacao_premio("nivel3", tipo_atuacao) + inicio = self._parse_date(a.get("inicio")) + ano = inicio.year if inicio else datetime.now().year + premiacoes.append( + Premiacao( + tipo=tipo_atuacao, + nome_premio=a.get("descricao", "N/A"), + ano=ano, + pontos=int(pontos), + ) + ) + + return premiacoes async def _construir_consultor(self, doc: Dict[str, Any]) -> Consultor: id_pessoa = doc["id"] diff --git a/backend/src/interface/schemas/consultor_schema.py b/backend/src/interface/schemas/consultor_schema.py index 872dee1..f892c9b 100644 --- a/backend/src/interface/schemas/consultor_schema.py +++ b/backend/src/interface/schemas/consultor_schema.py @@ -34,6 +34,10 @@ class ConsultoriaSchema(BaseModel): ultimo_evento: str vezes_responsavel: int areas: List[str] + situacao: str + anos_completos: int + anos_consecutivos: int + retornos: int class PremiacaoSchema(BaseModel):