diff --git a/sapl/integracao_hub/serializacao.py b/sapl/integracao_hub/serializacao.py index 58bfa4f93..77b76140c 100644 --- a/sapl/integracao_hub/serializacao.py +++ b/sapl/integracao_hub/serializacao.py @@ -214,12 +214,19 @@ def serializar_pendencia(alvo, request): # são a forma canônica que o próprio SAPL usa ao chamar o microserviço. Duplicar # a montagem é o caminho conhecido para as duas divergirem na primeira mudança. return { - # Keyset da fonte: o hub le `id` no topo e devolve como `id_gt`, e a - # view filtra `materia_id__gt` (o alvo e OneToOne com a materia, ver - # AssinaturasPendentesPollView). Aqui vai o id da MATERIA, nao o do - # registro de pendencia — emitir alvo.pk faria o hub pedir uma pagina - # que a view nunca entende, relendo a mesma primeira pagina para sempre. + # Keyset da fonte: cursor composto `(gerado_em, id)`, o mesmo de + # assinaturas-concluidas. O `id` e o da MATERIA, nao o do registro de + # pendencia — emitir alvo.pk faria o hub pedir uma pagina que a view + # nunca entende, relendo a mesma primeira pagina para sempre. 'id': materia.pk, + # A DATA e o que salva materia que materializa TARDE. Com keyset so por + # id, alvo criado depois com id abaixo do cursor era pulado para + # sempre, calado: em 22/08/2026 as 861 materias DOCX de Franco (todas + # com id < 1076, cursor em 1078) iam sumir inteiras no dia em que a + # conversao voltasse a funcionar. `gerado_em` e auto_now, entao a + # retificacao tambem reapresenta a materia sozinha — que e o + # comportamento desejado de qualquer forma (§5.1). + 'gerado_em': _iso(alvo.gerado_em), 'materia': { 'id': materia.pk, 'numero': materia.numero, diff --git a/sapl/integracao_hub/tests/test_assinatura.py b/sapl/integracao_hub/tests/test_assinatura.py index 488d01864..b24d598a8 100644 --- a/sapl/integracao_hub/tests/test_assinatura.py +++ b/sapl/integracao_hub/tests/test_assinatura.py @@ -611,3 +611,98 @@ def test_pendentes_traz_id_da_materia_no_topo(cliente_hub): item = next(i for i in resposta.data['resultados'] if i['materia']['id'] == materia.pk) assert item['id'] == materia.pk + + +# --------------------------------------------------------------------------- +# Cursor composto (gerado_em, id) — a matéria que materializa TARDE +# --------------------------------------------------------------------------- + +@pytest.mark.django_db(transaction=False) +def test_pendentes_traz_gerado_em_para_o_cursor(cliente_hub): + """Sem o campo o hub não tem como montar `CursorPorData` e a fonte trava.""" + materia, alvo = criar_materia_com_alvo() + + resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/', + {'desde': '1970-01-01T00:00:00+00:00', + 'id_gt': 0}) + + item = next(i for i in resposta.data['resultados'] + if i['materia']['id'] == materia.pk) + assert item['gerado_em'] + + +@pytest.mark.django_db(transaction=False) +def test_alvo_materializado_depois_com_id_menor_ainda_e_lido(cliente_hub): + """O modo de falha que ia engolir as 861 matérias de Franco. + + Matéria ANTIGA (id baixo) que só materializa hoje: com keyset por id ela + nasce abaixo do cursor e nunca mais é lida — sem erro e sem WARN. Pela + data, ela entra normalmente. + """ + antiga = baker.make(MateriaLegislativa, numero_protocolo=201) + recente, alvo_recente = criar_materia_com_alvo() + assert antiga.pk < recente.pk + + # o hub já andou até depois da matéria recente + cursor_desde = alvo_recente.gerado_em + cursor_id = recente.pk + + # ...e SÓ AGORA a antiga materializa (conversão DOCX voltou a funcionar) + antiga.texto_original.save('texto.pdf', ContentFile(PDF_ALVO), save=True) + alvo_antigo = DocumentoParaAssinatura( + materia=antiga, + hash_sha256=hashlib.sha256(PDF_ALVO).hexdigest(), + hash_origem=hashlib.sha256(PDF_ALVO).hexdigest()) + alvo_antigo.arquivo.save('materia_%s_alvo.pdf' % antiga.pk, + ContentFile(PDF_ALVO), save=True) + + resposta = cliente_hub.get( + BASE + 'assinaturas-pendentes/', + {'desde': cursor_desde.isoformat(), 'id_gt': cursor_id}) + + ids = [i['materia']['id'] for i in resposta.data['resultados']] + assert antiga.pk in ids + + +@pytest.mark.django_db(transaction=False) +def test_empate_de_gerado_em_nao_trava_o_cursor(cliente_hub): + """Passada de recuperação materializa em lote — timestamps empatam. + + Sem o desempate por id, uma página inteira no mesmo instante devolveria + sempre a mesma primeira página, para sempre (mesmo racional do §1.1). + """ + primeira, alvo_a = criar_materia_com_alvo() + segunda, alvo_b = criar_materia_com_alvo() + instante = alvo_a.gerado_em + DocumentoParaAssinatura.objects.filter( + pk__in=[alvo_a.pk, alvo_b.pk]).update(gerado_em=instante) + + resposta = cliente_hub.get( + BASE + 'assinaturas-pendentes/', + {'desde': instante.isoformat(), 'id_gt': primeira.pk}) + + ids = [i['materia']['id'] for i in resposta.data['resultados']] + assert primeira.pk not in ids + assert segunda.pk in ids + + +@pytest.mark.django_db(transaction=False) +def test_sem_desde_mantem_o_keyset_antigo_por_id(cliente_hub): + """Compatibilidade de subida: hub da versão anterior só manda `id_gt`.""" + primeira, _ = criar_materia_com_alvo() + segunda, _ = criar_materia_com_alvo() + + resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/', + {'id_gt': primeira.pk}) + + ids = [i['materia']['id'] for i in resposta.data['resultados']] + assert primeira.pk not in ids + assert segunda.pk in ids + + +@pytest.mark.django_db(transaction=False) +def test_desde_invalido_e_400(cliente_hub): + resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/', + {'desde': 'ontem', 'id_gt': 0}) + + assert resposta.status_code == 400 diff --git a/sapl/integracao_hub/views.py b/sapl/integracao_hub/views.py index 0d7dba4bc..9debb3794 100644 --- a/sapl/integracao_hub/views.py +++ b/sapl/integracao_hub/views.py @@ -319,13 +319,31 @@ class InventarioView(PollView): class AssinaturasPendentesPollView(PollView): - """Fonte de poll da pendência de assinatura (refinamento §3, keyset por id). + """Fonte de poll da pendência de assinatura — cursor composto `(gerado_em, id)`. SÓ devolve matéria com o PDF-alvo já materializado (§5.1): DOCX ainda não - convertido não sai do SAPL — segue visível apenas na tela local. O cursor - anda sobre o id da MATÉRIA (o alvo é OneToOne), então página sem pendência - por autor ainda avança o cursor — item com `autores_pendentes` vazio é - ruído inofensivo, nunca loop. + convertido não sai do SAPL — segue visível apenas na tela local. Item com + `autores_pendentes` vazio é ruído inofensivo, nunca loop: o cursor avança + igual. + + **Por que a data entrou no cursor (22/08/2026).** Esta fonte era keyset puro + por `materia_id`, e a materialização é justamente o passo que pode acontecer + MUITO depois do protocolo. Alvo criado hoje para uma matéria antiga nasce com + id abaixo do cursor e nunca mais é lido — sem erro, sem WARN, e a + reconciliação não cobre assinatura. No acervo de Franco isso valia o acervo + inteiro: 861 matérias DOCX paradas por conversão quebrada, TODAS com id < + 1076, contra um cursor em 1078. No dia em que a conversão voltasse a + funcionar, as 861 materializariam de uma vez e sumiriam todas. + + Ordenar por `gerado_em` mata isso na raiz: quem materializa tarde entra pela + data, não pelo id. E como `gerado_em` é `auto_now`, a retificação + reapresenta a matéria sozinha — o app precisa saber que o alvo mudou (§5.1), + e o dedupe do hub (marcador = hash do documento) mata a releitura do mesmo + estado. + + `desde` ausente mantém o keyset antigo por id, para o hub de versão anterior + continuar funcionando durante a subida. Some quando as duas pontas estiverem + na nova versão. """ def get(self, request, *args, **kwargs): @@ -333,12 +351,26 @@ class AssinaturasPendentesPollView(PollView): if id_gt is None: return Response({'detalhe': 'id_gt deve ser inteiro'}, status=status.HTTP_400_BAD_REQUEST) + alvos = (DocumentoParaAssinatura.objects - .filter(materia_id__gt=id_gt) .select_related('materia') .prefetch_related( - 'materia__autoria_set__autor__operadorautor_set__user') - .order_by('materia_id')[:self._limite(request)]) + 'materia__autoria_set__autor__operadorautor_set__user')) + + if 'desde' in request.query_params: + desde = self._desde(request) + if desde is None: + return Response( + {'detalhe': 'desde deve ser um datetime ISO-8601'}, + status=status.HTTP_400_BAD_REQUEST) + depois_do_instante = Q(gerado_em__gt=desde) + no_mesmo_instante = Q(gerado_em=desde) & Q(materia_id__gt=id_gt) + alvos = (alvos.filter(depois_do_instante | no_mesmo_instante) + .order_by('gerado_em', 'materia_id')) + else: + alvos = alvos.filter(materia_id__gt=id_gt).order_by('materia_id') + + alvos = alvos[:self._limite(request)] return Response({'resultados': [ serializar_pendencia(alvo, request) for alvo in alvos]})