Releitura da operadora Bradesco

This commit is contained in:
Gabriel 2026-09-29 13:48:12 -03:00
parent b61151846d
commit 7f6db34942
9 changed files with 309 additions and 161 deletions

View File

@ -153,7 +153,10 @@
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\33b72ffc-a7b3-4b7e-8afe-bb65121e9a85\\\\scratchpad\\\\testa_removidos.py\")", "PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\33b72ffc-a7b3-4b7e-8afe-bb65121e9a85\\\\scratchpad\\\\testa_removidos.py\")",
"PowerShell(.venv\\\\Scripts\\\\python.exe manage.py migrate portal_api 0080)", "PowerShell(.venv\\\\Scripts\\\\python.exe manage.py migrate portal_api 0080)",
"Bash(file *)", "Bash(file *)",
"Bash(iconv -f latin1 -t utf-8)" "Bash(iconv -f latin1 -t utf-8)",
"Bash(sed -i '/pagos_sugeridos/d' portal_api/conciliacao_fornecedores/alertas.py)",
"Bash(.venv/Scripts/python.exe -c \"import ast,sys;ast.parse\\(open\\('portal_api/conciliacao_fornecedores/alertas.py',encoding='utf-8'\\).read\\(\\)\\);print\\('ok'\\)\")",
"Bash(.venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/ec809504-f14e-4b2a-bb2e-17dc3759e548/scratchpad/t1.py\")"
], ],
"additionalDirectories": [ "additionalDirectories": [
"C:\\Users\\Depaula\\AppData\\Local\\Temp\\claude\\c--Users-Depaula-Documents-Portal\\3ea0ee22-e5fd-4030-98b1-ee2e71c16ce0\\scratchpad\\halloween-design", "C:\\Users\\Depaula\\AppData\\Local\\Temp\\claude\\c--Users-Depaula-Documents-Portal\\3ea0ee22-e5fd-4030-98b1-ee2e71c16ce0\\scratchpad\\halloween-design",

View File

@ -23,7 +23,7 @@ A ferramenta atende hoje várias empresas/operadoras reais, nenhuma com tratamen
| `itamed_saude` (3755) | nome | 11 | Sim (221, 197, 1684, 626) | | `itamed_saude` (3755) | nome | 11 | Sim (221, 197, 1684, 626) |
| `dental_uni_odonto_mensalidade` (Dental Uni) | nome | 2 | **Não**, as 2 existentes estão em "revisão". **Tem 2 layouts de relatório**: um com `[Nº Cartão]` entre colchetes e indentação distinguindo titular/dependente (validado empresa 1084), outro sem colchete (Nº Cartão solto) e mesma indentação para titular/dependente, distinguido pela presença de "Total Fam" (validado empresa 503, "TAROBA CONSTRUCOES LTDA", 27/08/2026) — ver `operadoras/dental_uni/odonto_mensalidade.py` | | `dental_uni_odonto_mensalidade` (Dental Uni) | nome | 2 | **Não**, as 2 existentes estão em "revisão". **Tem 2 layouts de relatório**: um com `[Nº Cartão]` entre colchetes e indentação distinguindo titular/dependente (validado empresa 1084), outro sem colchete (Nº Cartão solto) e mesma indentação para titular/dependente, distinguido pela presença de "Total Fam" (validado empresa 503, "TAROBA CONSTRUCOES LTDA", 27/08/2026) — ver `operadoras/dental_uni/odonto_mensalidade.py` |
| `unimed_oeste_pr_saude` (4709) | nome | 3 | Sim, mas de uma execução **anterior** à empresa 1601 hoje cadastrada (a de 1601 está em revisão) | | `unimed_oeste_pr_saude` (4709) | nome | 3 | Sim, mas de uma execução **anterior** à empresa 1601 hoje cadastrada (a de 1601 está em revisão) |
| `bradesco_saude` (1386) | nome | 1 | Sim (empresa 221) | | `bradesco_saude` (1386) | nome | 1 | Sim (empresa 221). PDF sem texto (OCR via Docling); desde 09/2026 lê linha a linha pelas posições OCR (não pelas células da tabela, que o modelo funde) e confere contra o "(TS)TOTAIS DA SUBFATURA" do Resumo; um beneficiário pode ter vários lançamentos (inclusão retroativa), ver rodada 136 em `portal_api/planos_saude/CHANGELOG.md` |
| `bradesco_dental_odonto_mensalidade` (3759) | nome | 1 | Sim (empresa 1684). **Ver ressalva abaixo** | | `bradesco_dental_odonto_mensalidade` (3759) | nome | 1 | Sim (empresa 1684). **Ver ressalva abaixo** |
| `unimed_vitoria_saude` (4750) | nome | 1 | Sim (empresa 792) | | `unimed_vitoria_saude` (4750) | nome | 1 | Sim (empresa 792) |
| `sulamerica_odonto_mensalidade` (4726) | CPF | 1 | Sim (empresa 792) | | `sulamerica_odonto_mensalidade` (4726) | CPF | 1 | Sim (empresa 792) |

View File

@ -175,3 +175,11 @@ O que mudou:
- Tela: abas "Conciliação" / "Em aberto há mais de 3 meses" no detalhe; o card de vencidos abre a aba nova; a linha de fornecedor sem alerta diz "N título(s) em aberto, sem pagamento a conciliar". - Tela: abas "Conciliação" / "Em aberto há mais de 3 meses" no detalhe; o card de vencidos abre a aba nova; a linha de fornecedor sem alerta diz "N título(s) em aberto, sem pagamento a conciliar".
Efeito na conciliação 20 (empresa 2017, testado dentro de transação desfeita): antes 146 conciliadas / 52 pendentes / 61 atenção / 1 divergente, 149 validadas; depois 233 conciliadas / 26 atenção / 1 divergente, 234 validadas (as 87 conciliadas com título em aberto, todas validadas automaticamente). 57 fornecedores com título antigo na aba nova. `validar-vencidos/` testado sem alterar a validação da conciliação; segunda abertura não sincroniza mais nada. A tela não foi renderizada neste ambiente. Efeito na conciliação 20 (empresa 2017, testado dentro de transação desfeita): antes 146 conciliadas / 52 pendentes / 61 atenção / 1 divergente, 149 validadas; depois 233 conciliadas / 26 atenção / 1 divergente, 234 validadas (as 87 conciliadas com título em aberto, todas validadas automaticamente). 57 fornecedores com título antigo na aba nova. `validar-vencidos/` testado sem alterar a validação da conciliação; segunda abertura não sincroniza mais nada. A tela não foi renderizada neste ambiente.
### Rodada 35 — Sugestão do título só com base concreta (2026-09-29)
Pedido do usuário (BRASPRESS, título de R$ 42,74 com a sugestão "Pode ter sido quitado por 1 pagamento(s) não vinculado(s)"): quando não há nada que indique qual pagamento se refere ao título, não mostrar sugestão. Essa frase (e a variante "Pode ter sido pago parcialmente...") saía só da distribuição por ordem de antiguidade, então valia para quase todo título. Em `_analisa_pendentes()` (`alertas.py`), o lado do título agora só tem sugestão quando há base concreta: par de valor próximo (texto de antes), par de mesmo valor ("Mesmo valor do pagamento de DD/MM/AAAA. Vincule para confirmar.", antes caía no texto genérico) ou pagamento(s) identificados como parcela do título ("Possível parcelamento: N pagamento(s) de parcela deste título, somando R$ X; R$ Y ainda em aberto."). Nos demais casos, sem sugestão e sem `relacionados`. O lado do pagamento não mudou ("Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ..." continua, pedido anterior do usuário). Testado com dados sintéticos; não testado na tela neste ambiente.
### Rodada 36 — "Em aberto há mais de 3 meses" deixa de ser situação no razão (2026-09-29)
Pedido do usuário: no razão da conta expandida, "Em aberto há mais de 3 meses." não é uma situação, já que existe a aba própria para esses títulos. A `descricao` da categoria `vencido` passou a ser "Em aberto." (mesmo texto de `em_aberto`), e `.conc-situacao--vencido` perdeu a cor dourada (fica com a cor neutra de `.conc-situacao`). A categoria `vencido` continua no payload, porque alimenta a aba "Em aberto há mais de 3 meses" (`titulosVencidos()`), o `total_vencido` e o card do topo. O XLSX (aba Pendências) passa a mostrar "Em aberto." para esses títulos; a coluna "Em aberto há mais de 3 meses" do Resumo não mudou.

View File

@ -44,12 +44,12 @@ Resultado no arquivo real: 532 vínculos exatos, 99 combinações, 20 compensaç
**Fato x sugestão: só vínculo muda valor** (pedido do usuário). Um título sem vínculo está em aberto pelo **valor cheio**; um pagamento sem vínculo é "Pagamento não vinculado". O razão não traz parcelamento, vencimento nem NF no pagamento, então qualquer dedução sobre qual título um pagamento paga é **sugestão**: vai em `SituacaoItem.sugestao` (texto à parte), nunca na `descricao` (só fato) nem em `valor_em_aberto`. Na tela, a sugestão aparece numa linha própria abaixo do fato, com a etiqueta "Sugestão", em itálico e cor neutra; no XLSX, depois de "Sugestão:". O contador confirma criando o vínculo manual (inclusive o de pagamento parcial, que aí sim reduz o valor em aberto). **Fato x sugestão: só vínculo muda valor** (pedido do usuário). Um título sem vínculo está em aberto pelo **valor cheio**; um pagamento sem vínculo é "Pagamento não vinculado". O razão não traz parcelamento, vencimento nem NF no pagamento, então qualquer dedução sobre qual título um pagamento paga é **sugestão**: vai em `SituacaoItem.sugestao` (texto à parte), nunca na `descricao` (só fato) nem em `valor_em_aberto`. Na tela, a sugestão aparece numa linha própria abaixo do fato, com a etiqueta "Sugestão", em itálico e cor neutra; no XLSX, depois de "Sugestão:". O contador confirma criando o vínculo manual (inclusive o de pagamento parcial, que aí sim reduz o valor em aberto).
As sugestões saem de uma **distribuição** dos pagamentos não vinculados sobre os títulos em aberto: antes de tudo, numa passada própria anterior à ordem por data, os **pares de mesmo valor** (título × pagamento, desempate igual ao `_pareia_exato` do motor: data mais próxima, depois pagamento após o título; sugestão "Mesmo valor do título de ..."); em seguida, também em passada própria, os **pares de valor próximo** dentro da tolerância da conciliação (`analisa_conta(..., tolerancia=)`, a mesma do cabeçalho: R$ 10 ou 2%, o maior), 1:1, menor diferença e depois data mais próxima, com a sugestão "Possível pagamento do título de ..., com R$ 6,20 a menos (possível desconto)" / "a mais (possível juros/multa)" dos dois lados; depois o título do qual o pagamento parece ser **parcela** (título ≈ k × pagamento, k de 2 a 24, até 1 centavo por parcela; se nenhum título bater assim, aceita o pagamento a até `TOLERANCIA_PERCENTUAL_PADRAO`% (2%) da parcela arredondada mais próxima, só o percentual, sem o piso fixo de R$ 10, e a sugestão diz a diferença; a sugestão sempre diz a quantidade de parcelas: "em 3x de R$ 552,27" / "em 4x de R$ 2.163,56, com R$ 0,01 a mais (juros/desconto?)"; ver `_parcela()` em `alertas.py`), senão o título mais antigo com saldo (sugestão fraca, e o texto diz isso: "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ..."; com mais de um título, acrescenta "Soma: R$ X; saldo residual de R$ Y em aberto." ou "...; o pagamento excede em R$ Y.", sendo a soma do **saldo ainda disponível** de cada título na distribuição, não do valor cheio). Ela serve só para escrever as sugestões e para achar o **excedente** (pagamentos não vinculados além de todos os títulos em aberto), que é fato e é a divergência. Motivo de existir: o arquivo real tem muitos parcelamentos (NF 1.299,00 em 3 × 433,00; NF 25.325,00 em 5 × 5.065,00), e a sugestão aponta ao contador o título provável. As sugestões saem de uma **distribuição** dos pagamentos não vinculados sobre os títulos em aberto: antes de tudo, numa passada própria anterior à ordem por data, os **pares de mesmo valor** (título × pagamento, desempate igual ao `_pareia_exato` do motor: data mais próxima, depois pagamento após o título; sugestão "Mesmo valor do título de ..."); em seguida, também em passada própria, os **pares de valor próximo** dentro da tolerância da conciliação (`analisa_conta(..., tolerancia=)`, a mesma do cabeçalho: R$ 10 ou 2%, o maior), 1:1, menor diferença e depois data mais próxima, com a sugestão "Possível pagamento do título de ..., com R$ 6,20 a menos (possível desconto)" / "a mais (possível juros/multa)" dos dois lados; depois o título do qual o pagamento parece ser **parcela** (título ≈ k × pagamento, k de 2 a 24, até 1 centavo por parcela; se nenhum título bater assim, aceita o pagamento a até `TOLERANCIA_PERCENTUAL_PADRAO`% (2%) da parcela arredondada mais próxima, só o percentual, sem o piso fixo de R$ 10, e a sugestão diz a diferença; a sugestão sempre diz a quantidade de parcelas: "em 3x de R$ 552,27" / "em 4x de R$ 2.163,56, com R$ 0,01 a mais (juros/desconto?)"; ver `_parcela()` em `alertas.py`), senão o título mais antigo com saldo (sugestão fraca, e o texto diz isso: "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ..."; com mais de um título, acrescenta "Soma: R$ X; saldo residual de R$ Y em aberto." ou "...; o pagamento excede em R$ Y.", sendo a soma do **saldo ainda disponível** de cada título na distribuição, não do valor cheio). Do lado do **título**, só aparece sugestão com base concreta (pedido do usuário, rodada 35 de `CHANGELOG.md` nesta pasta): par de mesmo valor ("Mesmo valor do pagamento de ..."), par de valor próximo ou pagamento(s) identificados como parcela dele ("Possível parcelamento: N pagamento(s) de parcela deste título, somando R$ X; R$ Y ainda em aberto."); ter sido abatido só pela ordem de antiguidade não gera sugestão nem `relacionados` no título, porque valia para quase todos. A distribuição serve só para escrever as sugestões e para achar o **excedente** (pagamentos não vinculados além de todos os títulos em aberto), que é fato e é a divergência. Motivo de existir: o arquivo real tem muitos parcelamentos (NF 1.299,00 em 3 × 433,00; NF 25.325,00 em 5 × 5.065,00), e a sugestão aponta ao contador o título provável.
| Categoria | Significado (fato) | Alerta | | Categoria | Significado (fato) | Alerta |
|---|---|---| |---|---|---|
| `em_aberto` | título sem pagamento vinculado (ou saldo de um pagamento parcial vinculado) | nenhum | | `em_aberto` | título sem pagamento vinculado (ou saldo de um pagamento parcial vinculado) | nenhum |
| `vencido` | idem, há mais de 3 meses até a data-base | **nenhum** (desde a rodada 34 de `CHANGELOG.md` nesta pasta: "não é bloqueio para análise"; vai para `total_vencido` e para a aba "Em aberto há mais de 3 meses") | | `vencido` | idem, há mais de 3 meses até a data-base | **nenhum** (desde a rodada 34 de `CHANGELOG.md` nesta pasta: "não é bloqueio para análise"; vai para `total_vencido` e para a aba "Em aberto há mais de 3 meses"). Desde a rodada 36, a `descricao` é "Em aberto." (igual a `em_aberto`, sem cor de destaque): no razão da aba "Conciliação" não é situação; a categoria só alimenta a aba própria |
| `nao_vinculado` | pagamento ainda não vinculado a um título | média (`pagamento_sem_titulo`, "N pagamentos ainda não vinculados, somando R$ X") | | `nao_vinculado` | pagamento ainda não vinculado a um título | média (`pagamento_sem_titulo`, "N pagamentos ainda não vinculados, somando R$ X") |
| `excedente` | pagamento além de todos os títulos em aberto | **alta (divergência)** | | `excedente` | pagamento além de todos os títulos em aberto | **alta (divergência)** |

View File

@ -173,7 +173,6 @@ def _analisa_pendentes(
# Distribuição sugerida: não altera nenhum valor exibido, só alimenta os # Distribuição sugerida: não altera nenhum valor exibido, só alimenta os
# textos de sugestão e a detecção do excedente. # textos de sugestão e a detecção do excedente.
saldo_sugerido = {c.id: c.valor for c in creditos} saldo_sugerido = {c.id: c.valor for c in creditos}
pagos_sugeridos: dict[int, list[int]] = {c.id: [] for c in creditos}
situacoes: dict[int, SituacaoItem] = {} situacoes: dict[int, SituacaoItem] = {}
total_nao_vinculado = Decimal("0") total_nao_vinculado = Decimal("0")
total_excedente = Decimal("0") total_excedente = Decimal("0")
@ -201,7 +200,6 @@ def _analisa_pendentes(
par_exato[debito.id] = credito par_exato[debito.id] = credito
creditos_pareados.add(credito.id) creditos_pareados.add(credito.id)
saldo_sugerido[credito.id] = Decimal("0") saldo_sugerido[credito.id] = Decimal("0")
pagos_sugeridos[credito.id].append(debito.id)
# 2º: valor próximo, dentro da tolerância da conciliação (possível # 2º: valor próximo, dentro da tolerância da conciliação (possível
# juros/desconto). Antes era vínculo automático do motor; o usuário pediu # juros/desconto). Antes era vínculo automático do motor; o usuário pediu
@ -226,9 +224,12 @@ def _analisa_pendentes(
par_proximo[debito.id] = credito par_proximo[debito.id] = credito
creditos_pareados.add(credito.id) creditos_pareados.add(credito.id)
saldo_sugerido[credito.id] = Decimal("0") saldo_sugerido[credito.id] = Decimal("0")
pagos_sugeridos[credito.id].append(debito.id)
titulo_proximo = {credito.id: debito_id for debito_id, credito in par_proximo.items()} titulo_proximo = {credito.id: debito_id for debito_id, credito in par_proximo.items()}
titulo_exato = {credito.id: debito_id for debito_id, credito in par_exato.items()}
por_id_debito = {d.id: d for d in debitos} por_id_debito = {d.id: d for d in debitos}
# Pagamentos que parecem parcela de cada título (base concreta da sugestão
# do lado do título, ao contrário da ordem de antiguidade).
parcelas_do_titulo: dict[int, list[int]] = {c.id: [] for c in creditos}
for debito in debitos: for debito in debitos:
if debito.id in par_proximo: if debito.id in par_proximo:
@ -278,11 +279,11 @@ def _analisa_pendentes(
saldo_sugerido[credito.id] -= abatido saldo_sugerido[credito.id] -= abatido
restante -= abatido restante -= abatido
usados.append(credito.id) usados.append(credito.id)
pagos_sugeridos[credito.id].append(debito.id)
titulos = [c for c in creditos if c.id in usados] titulos = [c for c in creditos if c.id in usados]
if escolhida and usados == [parcela_de.id]: if escolhida and usados == [parcela_de.id]:
qtd_parcelas, referencia, diferenca = escolhida[1] qtd_parcelas, referencia, diferenca = escolhida[1]
parcelas_do_titulo[parcela_de.id].append(debito.id)
# Quantidade de parcelas no texto (pedido do usuário). # Quantidade de parcelas no texto (pedido do usuário).
if diferenca == 0: if diferenca == 0:
sugestao = ( sugestao = (
@ -331,27 +332,37 @@ def _analisa_pendentes(
total_aberto = Decimal("0") total_aberto = Decimal("0")
total_vencido = Decimal("0") total_vencido = Decimal("0")
for credito in creditos: for credito in creditos:
pagos = pagos_sugeridos[credito.id] # Só sugestão com base concreta (mesmo valor, valor próximo ou parcela).
coberto = credito.valor - saldo_sugerido[credito.id] # Ter sido abatido pela ordem de antiguidade não indica nada sobre este
if not pagos: # título e valia para todos, então não vira sugestão (pedido do usuário).
sugestao = "" if credito.id in titulo_proximo:
elif credito.id in titulo_proximo:
debito = por_id_debito[titulo_proximo[credito.id]] debito = por_id_debito[titulo_proximo[credito.id]]
diferenca = credito.valor - debito.valor diferenca = credito.valor - debito.valor
pagos = [debito.id]
sugestao = ( sugestao = (
f"Possível pagamento em {debito.data:%d/%m/%Y} ({_moeda(debito.valor)}), com {_moeda(diferenca)} " f"Possível pagamento em {debito.data:%d/%m/%Y} ({_moeda(debito.valor)}), com {_moeda(diferenca)} "
f"{_natureza_diferenca(diferenca)}. Vincule para confirmar." f"{_natureza_diferenca(diferenca)}. Vincule para confirmar."
) )
elif saldo_sugerido[credito.id] <= 0: elif credito.id in titulo_exato:
sugestao = f"Pode ter sido quitado por {len(pagos)} pagamento(s) não vinculado(s). Vincule para confirmar." debito = por_id_debito[titulo_exato[credito.id]]
pagos = [debito.id]
sugestao = f"Mesmo valor do pagamento de {debito.data:%d/%m/%Y}. Vincule para confirmar."
elif parcelas_do_titulo[credito.id]:
pagos = parcelas_do_titulo[credito.id]
soma = sum((por_id_debito[i].valor for i in pagos), Decimal("0"))
sugestao = f"Possível parcelamento: {len(pagos)} pagamento(s) de parcela deste título, somando {_moeda(soma)}"
restante_titulo = credito.valor - soma
sugestao += f"; {_moeda(restante_titulo)} ainda em aberto." if restante_titulo > 0 else "."
else: else:
sugestao = f"Pode ter sido pago parcialmente ({_moeda(coberto)}) por pagamento(s) não vinculado(s)." pagos = []
sugestao = ""
total_aberto += credito.valor total_aberto += credito.valor
if _soma_meses(credito.data, MESES_VENCIDO) < data_base: if _soma_meses(credito.data, MESES_VENCIDO) < data_base:
total_vencido += credito.valor total_vencido += credito.valor
situacoes[credito.id] = SituacaoItem( # A categoria continua `vencido` (alimenta a aba própria dos títulos
CATEGORIA_VENCIDO, credito.valor, f"Em aberto há mais de {MESES_VENCIDO} meses.", sugestao, pagos # antigos), mas o texto é o mesmo do título em aberto: no razão da
) # conciliação não é situação a tratar (pedido do usuário).
situacoes[credito.id] = SituacaoItem(CATEGORIA_VENCIDO, credito.valor, "Em aberto.", sugestao, pagos)
else: else:
situacoes[credito.id] = SituacaoItem(CATEGORIA_EM_ABERTO, credito.valor, "Em aberto.", sugestao, pagos) situacoes[credito.id] = SituacaoItem(CATEGORIA_EM_ABERTO, credito.valor, "Em aberto.", sugestao, pagos)

View File

@ -403,3 +403,11 @@ Validado contra o arquivo real: 6 beneficiários, R$ 2.102,94, batendo com o "To
Na importação da empresa **792 (Weitnauer)**, competência 09/2026, a ferramenta não achou nenhum beneficiário, embora o layout fosse idêntico ao de 08/2026. Causa: o PDF novo foi gerado com uma fonte de símbolo, e o `pdfplumber` devolve cada caractere deslocado em 0xF000 na Área de Uso Privado do Unicode ("4" como U+F034, espaço como U+F020, "é" como U+F0E9). Visualmente nada muda, mas nenhum dígito casava com `_LINHA_BENEFICIARIO_RE`. Na importação da empresa **792 (Weitnauer)**, competência 09/2026, a ferramenta não achou nenhum beneficiário, embora o layout fosse idêntico ao de 08/2026. Causa: o PDF novo foi gerado com uma fonte de símbolo, e o `pdfplumber` devolve cada caractere deslocado em 0xF000 na Área de Uso Privado do Unicode ("4" como U+F034, espaço como U+F020, "é" como U+F0E9). Visualmente nada muda, mas nenhum dígito casava com `_LINHA_BENEFICIARIO_RE`.
Corrigido em `operadoras/sulamerica/odonto_mensalidade.py` com `_normaliza_fonte_simbolo()`, aplicada a cada linha montada por `_agrupa_linhas`: converte U+F020 a U+F0FF de volta pro Latin-1 correspondente e normaliza os espaços. Arquivo sem esse deslocamento passa inalterado. Validado contra o arquivo real: 15 beneficiários (5 titulares, 10 dependentes), R$ 437,40, batendo com o "Total" e os totalizadores impressos; acentos saem corretos ("ARLINDO JOSÉ"). Se outra operadora em PDF passar a dar "nenhum beneficiário" do nada, conferir com `repr()` se o texto veio nessa faixa U+F0xx. Corrigido em `operadoras/sulamerica/odonto_mensalidade.py` com `_normaliza_fonte_simbolo()`, aplicada a cada linha montada por `_agrupa_linhas`: converte U+F020 a U+F0FF de volta pro Latin-1 correspondente e normaliza os espaços. Arquivo sem esse deslocamento passa inalterado. Validado contra o arquivo real: 15 beneficiários (5 titulares, 10 dependentes), R$ 437,40, batendo com o "Total" e os totalizadores impressos; acentos saem corretos ("ARLINDO JOSÉ"). Se outra operadora em PDF passar a dar "nenhum beneficiário" do nada, conferir com `repr()` se o texto veio nessa faixa U+F0xx.
### Rodada 136 — Bradesco Saúde (1386): tabela com várias linhas fundidas e mais de um lançamento por beneficiário
Na importação da empresa **221 (Rossoni Piotto)**, competência 09/2026, a leitura falhava com "esperava 1 valor de 'valor_area' por linha de beneficiário (1 linhas), encontrei 7". Duas mudanças no PDF em relação ao de 08/2026: (1) o modelo de tabela do Docling (TableFormer) juntou as 7 linhas da tabela numa única linha, cada célula com a coluna inteira separada por espaço; (2) uma dependente incluída retroativamente (CRISTIANE, `0000002/01`) veio com 3 lançamentos (IR 08/2026, IR 09/2026, IM 10/2026), só o primeiro com Certif./Nome. O contorno anterior ("1 valor por beneficiário", redistribuído em ordem de leitura) não tinha como resolver nenhuma das duas coisas.
Corrigido em `operadoras/bradesco/saude.py`: a extração deixou de usar as células reconstruídas (`document.tables`) e passou a ler as palavras OCR com posição que o Docling guarda por trás de cada tabela (`pages[i].predictions.tablestructure`), reagrupadas em linhas físicas pela posição vertical. Cada campo é reconhecido pelo formato (Certif., Mês/Ano, valor), uma linha física vira um lançamento, e linha sem Certif. é somada ao beneficiário anterior. A rubrica passa a mostrar o Mês/Ano e o código de movimentação (ex.: "Mensalidade 08/2026 (IR)"). Conferência nova e obrigatória contra a linha "(TS)TOTAIS DA SUBFATURA" do quadro "Resumo" da fatura (valor, Part. Seg. e número de lançamentos); se não bater, a leitura falha em vez de lançar valor errado. Devolução (código CR, "(TD)" diferente de zero) ainda não apareceu em arquivo real, então essa conferência vai falhar de propósito no primeiro mês em que aparecer, até o formato ser confirmado.
Validado contra os dois arquivos reais da empresa 221: 09/2026 com 5 beneficiários, 7 lançamentos, R$ 8.641,73; 08/2026 com 4 beneficiários, R$ 4.656,02 (mesmo resultado de antes). Os dois batem com o Resumo impresso. O total do boleto (R$ 8.847,40) inclui R$ 205,67 de IOF, que não é lançamento de beneficiário.

View File

@ -33,7 +33,7 @@ Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` impleme
**`OperadoraParser.finaliza()`** (`operadoras/base.py`) — hook opcional chamado pelo `pipeline.processa_importacao` uma vez, depois que `extrai()` já rodou pra **todos** os arquivos da importação (default: não acrescenta nada, a maioria das operadoras nunca precisa sobrescrever). Existe pra operadora que só consegue decidir algo depois de ver o conjunto completo de arquivos anexados — hoje o único caso real é a Unimed Cascavel (ver abaixo), que usa isso pra escolher entre duas fontes possíveis de coparticipação sem correr risco de somar as duas. **`OperadoraParser.finaliza()`** (`operadoras/base.py`) — hook opcional chamado pelo `pipeline.processa_importacao` uma vez, depois que `extrai()` já rodou pra **todos** os arquivos da importação (default: não acrescenta nada, a maioria das operadoras nunca precisa sobrescrever). Existe pra operadora que só consegue decidir algo depois de ver o conjunto completo de arquivos anexados — hoje o único caso real é a Unimed Cascavel (ver abaixo), que usa isso pra escolher entre duas fontes possíveis de coparticipação sem correr risco de somar as duas.
**PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar. **PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. **As células reconstruídas pelo modelo de tabela (TableFormer, `document.tables`) não são confiáveis neste PDF**: em 08/2026 o valor de uma linha "vazava" pra célula da vizinha; em 09/2026 as 7 linhas vieram fundidas numa só, e um mesmo beneficiário passou a ter vários lançamentos (inclusão retroativa, uma linha por Mês/Ano, só a primeira com Certif./Nome). Por isso o parser lê as **palavras OCR com posição** que o Docling guarda por trás de cada tabela (`pages[i].predictions.tablestructure.table_map[...].cluster.cells`), reagrupa em linhas físicas pela posição vertical e reconhece cada campo pelo formato; uma linha física é um lançamento, linha sem Certif. soma no beneficiário anterior. A extração é conferida contra a linha "(TS)TOTAIS DA SUBFATURA" do quadro "Resumo" da própria fatura (valor, Part. Seg., número de lançamentos) e falha se não bater. Ver o docstring de `operadoras/bradesco/saude.py` (particularidade 6) e a rodada 136 em `CHANGELOG.md` desta pasta. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar.
**Bradesco Dental / "Bradesaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`<família>/00` = titular, `<família>/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde. **Bradesco Dental / "Bradesaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`<família>/00` = titular, `<família>/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde.

View File

@ -63,12 +63,17 @@ arquivo real (competência 08/2026, empresa 221 - Rossoni Piotto):
correta — mas como é a mesma classe de coluna estreita, aplicamos o correta — mas como é a mesma classe de coluna estreita, aplicamos o
mesmo contorno por precaução, sem custo nenhum quando não há bug). mesmo contorno por precaução, sem custo nenhum quando não há bug).
4. Cabeçalho da tabela não é identificado por posição fixa (linha X, SUBSTITUÍDO pela particularidade 6 (a partir de 09/2026): o contorno
coluna Y) — é identificado pelo TEXTO de cada coluna (normalizado, por "1 valor por linha" deixou de funcionar quando o mesmo
sem acento) via `_classifica_colunas`, porque o Docling pode beneficiário passou a ter mais de um lançamento. Mantido aqui só como
devolver a tabela de beneficiários paginada em mais de um objeto histórico do que o TableFormer já errou neste PDF.
`table` quando o PDF tiver muitas linhas (fatura de empresa grande) —
`extrai()` já concatena todas as tabelas cujo cabeçalho tem "CERTIF". 4. A tabela de beneficiários é identificada pelo TEXTO do cabeçalho
("Certif."), não por posição, porque o Docling pode devolver a tabela
paginada em mais de um objeto quando o PDF tiver muitas linhas
(fatura de empresa grande) — `_extrai_beneficiarios()` percorre todas
as tabelas, e uma linha de continuação no topo de uma tabela nova é
somada ao último beneficiário da anterior.
5. "Part. Seg." SEMPRE veio "0,00" nos dois arquivos de exemplo (não há 5. "Part. Seg." SEMPRE veio "0,00" nos dois arquivos de exemplo (não há
coparticipação neste plano até o momento) — por isso, na prática, coparticipação neste plano até o momento) — por isso, na prática,
@ -79,35 +84,82 @@ arquivo real (competência 08/2026, empresa 221 - Rossoni Piotto):
arquivo real com coparticipação — confirmar de novo se/quando a arquivo real com coparticipação — confirmar de novo se/quando a
Bradesco mandar um mês com valor diferente de zero nessa coluna. Bradesco mandar um mês com valor diferente de zero nessa coluna.
IMPORTANTE: como os outros parsers de PDF, este foi escrito e testado 6. (competência 09/2026, mesma empresa 221) o TableFormer juntou TODAS
contra um único arquivo real (competência 08/2026) — revalidar pelo as linhas de beneficiário numa única linha de tabela (cada célula
botão "Selecionar arquivo" (que já roda este mesmo parser) antes de com os 5-7 valores da coluna inteira separados por espaço), e o mesmo
confiar de olhos fechados num mês com um layout diferente (ex.: uma beneficiário passou a ter MAIS DE UM lançamento (inclusão retroativa:
família muito maior, ou mais de uma tabela por página). uma linha por Mês/Ano, só a primeira com Certif./Nome, as seguintes
só com Plano/Data Início/Mov./Mês/Ano/Valor/Part. Seg.). Com isso a
particularidade 3 deixou de valer: não existe mais "1 valor por
beneficiário" e a fronteira de linha se perdeu no modelo de tabela.
Por isso a extração NÃO usa mais as células da tabela reconstruída
(`document.tables`): lê as palavras OCR brutas que o Docling guarda
por trás de cada tabela (`pages[i].predictions.tablestructure`, cada
uma com sua posição na página), reagrupa em linhas físicas pela
posição vertical e classifica cada campo pelo FORMATO (Certif.
"0000001/00", Mês/Ano "10/2026", valor "1.456,24"), não pela coluna
em que o modelo o colocou. Uma linha física = um lançamento; linha sem
Certif. é continuação do beneficiário da linha anterior (somada a ele,
regra geral de "somar por indivíduo").
Conferência obrigatória: a soma de Valor/Part. Seg. e o número de
lançamentos extraídos precisam bater com a linha "(TS)TOTAIS DA
SUBFATURA" do quadro "Resumo" da própria fatura — se não bater,
levanta erro em vez de lançar valor errado. Devolução ("(TD)TOTAIS A
DEVOLVER" diferente de zero, código CR) ainda não apareceu em nenhum
arquivo real: se aparecer, essa conferência vai falhar de propósito
até o formato da linha de devolução ser confirmado.
IMPORTANTE: validado contra dois arquivos reais (08/2026 e 09/2026 da
empresa 221) — revalidar pelo botão "Selecionar arquivo" (que já roda
este mesmo parser) antes de confiar de olhos fechados num mês com um
layout diferente (ex.: uma família muito maior, ou mais de uma tabela
por página).
""" """
import re import re
from typing import Dict, List, Tuple from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from docling.datamodel.base_models import InputFormat from docling.datamodel.base_models import InputFormat
from docling.datamodel.document import ConversionResult
from docling.document_converter import DocumentConverter, PdfFormatOption from docling.document_converter import DocumentConverter, PdfFormatOption
from docling.datamodel.pipeline_options import PdfPipelineOptions from docling.datamodel.pipeline_options import PdfPipelineOptions
from docling_core.types.doc.items.table.table import TableItem
from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento, normaliza_nome from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento, normaliza_nome
from portal_api.planos_saude.operadoras.base import OperadoraParser from portal_api.planos_saude.operadoras.base import OperadoraParser
_CERTIF_RE = re.compile(r"^(?P<familia>\d+)/(?P<sufixo>\d{2})$") _CERTIF_RE = re.compile(r"^(?P<familia>\d+)/(?P<sufixo>\d{2})$")
_MONEY_RE = re.compile(r"\d{1,3}(?:\.\d{3})*,\d{2}") _MES_ANO_RE = re.compile(r"^\d{2}/\d{4}$")
_DATA_RE = re.compile(r"^\d{2}/\d{2}/\d{4}$")
_MONEY_RE = re.compile(r"^\d{1,3}(?:\.\d{3})*,\d{2}$")
_INTEIRO_RE = re.compile(r"^\d+$")
# Códigos de movimentação da coluna "M V." (os mesmos do quadro "Resumo").
_MOVIMENTOS = {"IM", "IR", "CM", "CR"}
_BUCKET_CERTIF = "certif" # Linhas da tabela têm ~7pt de altura (medido no arquivo real); duas
_BUCKET_NOME = "nome" # palavras cujo centro vertical difere menos que isso são da mesma linha.
_BUCKET_VALOR_AREA = "valor_area" # "Mês/Ano" + "Valor" juntas, ver particularidade 3 _TOLERANCIA_LINHA_PT = 3.0
_BUCKET_PART_SEG = "part_seg"
_BUCKET_IGNORAR = "ignorar"
_converter_singleton: DocumentConverter | None = None _converter_singleton: DocumentConverter | None = None
@dataclass
class _Palavra:
"""Um trecho OCR com posição na página (origem no topo, em pt)."""
x: float
y_centro: float
texto: str
@dataclass
class _Beneficiario:
certif: str
nome: str
lancamentos: List[Tuple[str, str, float, float]] = field(default_factory=list)
# cada lançamento: (mes_ano, mov, valor, part_seg)
def _converter() -> DocumentConverter: def _converter() -> DocumentConverter:
"""Instância única do conversor Docling, reaproveitada entre importações """Instância única do conversor Docling, reaproveitada entre importações
(carregar os modelos de OCR/layout é o custo fixo mais caro — ver nota de (carregar os modelos de OCR/layout é o custo fixo mais caro — ver nota de
@ -127,144 +179,211 @@ def _valor_para_float(texto: str) -> float:
return float(texto) if texto else 0.0 return float(texto) if texto else 0.0
def _classifica_coluna(texto_cabecalho: str) -> str: def _palavras_por_tabela(resultado: ConversionResult) -> List[List[_Palavra]]:
t = normaliza_nome(texto_cabecalho) """Palavras OCR de cada tabela detectada, na ordem das páginas (e de
if "CERTIF" in t: cima para baixo dentro da página) — ver particularidade 6."""
return _BUCKET_CERTIF tabelas: List[List[_Palavra]] = []
if "NOME" in t and "SEGURADO" in t: for pagina in resultado.pages:
return _BUCKET_NOME estrutura = pagina.predictions.tablestructure
if "PART" in t and "SEG" in t: if estrutura is None:
return _BUCKET_PART_SEG
if "VALOR" in t or "MES" in t:
return _BUCKET_VALOR_AREA
return _BUCKET_IGNORAR
def _tabela_eh_de_beneficiarios(table: TableItem) -> bool:
"""Identifica a tabela certa pelo TEXTO do cabeçalho (não por posição),
porque uma fatura de empresa grande pode paginar a tabela de
beneficiários em mais de um objeto `table` do Docling."""
return any(
cell.column_header and "CERTIF" in normaliza_nome(cell.text)
for cell in table.data.table_cells
)
def _agrupa_por_linha(table: TableItem) -> Tuple[List[int], Dict[int, Dict[str, List[str]]]]:
"""Classifica cada coluna pelo texto do cabeçalho e agrupa o texto de
cada célula de dado por (linha, categoria) — ver particularidade 4."""
colunas: Dict[int, str] = {}
for cell in table.data.table_cells:
if not cell.column_header:
continue continue
bucket = _classifica_coluna(cell.text) altura = pagina.size.height
for col in range(cell.start_col_offset_idx, cell.end_col_offset_idx): clusters = sorted(
if colunas.get(col) in (None, _BUCKET_IGNORAR): estrutura.table_map.values(),
colunas[col] = bucket key=lambda tab: tab.cluster.bbox.to_top_left_origin(altura).t,
)
linhas: Dict[int, Dict[str, List[str]]] = {} for tab in clusters:
for cell in table.data.table_cells: palavras: List[_Palavra] = []
if cell.column_header: for cell in tab.cluster.cells:
continue texto = cell.text.strip()
bucket = colunas.get(cell.start_col_offset_idx, _BUCKET_IGNORAR) if not texto:
if bucket == _BUCKET_IGNORAR or not cell.text.strip(): continue
continue bbox = cell.rect.to_bounding_box().to_top_left_origin(altura)
linhas.setdefault(cell.start_row_offset_idx, {}).setdefault(bucket, []).append(cell.text) palavras.append(_Palavra(x=bbox.l, y_centro=(bbox.t + bbox.b) / 2, texto=texto))
tabelas.append(palavras)
return sorted(linhas.keys()), linhas return tabelas
def _extrai_valores_area( def _agrupa_linhas(palavras: List[_Palavra]) -> List[List[_Palavra]]:
linhas_ordenadas: List[int], linhas: Dict[int, Dict[str, List[str]]], bucket: str """Reagrupa as palavras em linhas físicas pela posição vertical, cada
) -> List[float]: linha ordenada da esquerda para a direita."""
"""Concatena o texto do `bucket` de cada linha (na ordem de leitura, de linhas: List[List[_Palavra]] = []
cima para baixo) e extrai TODOS os valores monetários encontrados, centro_linha = 0.0
preservando essa ordem — ver particularidade 3 do módulo. Levanta for p in sorted(palavras, key=lambda p: p.y_centro):
ValueError se a contagem final não bater 1:1 com o número de linhas if linhas and abs(p.y_centro - centro_linha) < _TOLERANCIA_LINHA_PT:
(melhor falhar alto do que lançar um valor na pessoa errada).""" linhas[-1].append(p)
tokens: List[float] = [] centro_linha = sum(q.y_centro for q in linhas[-1]) / len(linhas[-1])
for idx in linhas_ordenadas: else:
texto = " ".join(linhas[idx].get(bucket, [])) linhas.append([p])
tokens.extend(_valor_para_float(v) for v in _MONEY_RE.findall(texto)) centro_linha = p.y_centro
return [sorted(linha, key=lambda p: p.x) for linha in linhas]
if len(tokens) != len(linhas_ordenadas):
raise ValueError(
f"Bradesco Saúde: esperava 1 valor de '{bucket}' por linha de beneficiário " def _tokens(linha: List[_Palavra]) -> List[str]:
f"({len(linhas_ordenadas)} linhas), encontrei {len(tokens)} — layout " """Texto da linha quebrado em tokens (o OCR às vezes junta dois campos
f"inesperado, revisar manualmente antes de confiar no resultado." numéricos vizinhos num trecho só, ex. '4 4')."""
return [t for p in linha for t in p.texto.split()]
def _limite_coluna_nome(linhas: List[List[_Palavra]]) -> Optional[float]:
"""Posição x onde termina a coluna "Nome Segurado": início do cabeçalho
"Subfatura Nº", cuja coluna (A/N) vem logo depois do nome."""
for linha in linhas:
for p in linha:
if normaliza_nome(p.texto).startswith("SUBFATURA"):
return p.x
return None
def _nome_da_linha(linha: List[_Palavra], certif: str, limite_nome: Optional[float]) -> str:
"""Trechos entre o Certif. e o fim da coluna "Nome Segurado" (sem o
cabeçalho, cai para o início da Data Nascimento)."""
x_certif = next(p.x for p in linha if certif in p.texto.split())
fim = limite_nome
if fim is None:
fim = next((p.x for p in linha if any(_DATA_RE.match(t) for t in p.texto.split())), None)
return " ".join(
p.texto for p in linha
if p.x > x_certif and (fim is None or p.x < fim)
).strip()
def _le_beneficiarios(
linhas: List[List[_Palavra]], atual: Optional[_Beneficiario]
) -> List[_Beneficiario]:
"""Uma linha física = um lançamento. Linha com Certif. abre um
beneficiário novo; linha sem Certif. é continuação do anterior (que
pode ter vindo da tabela/página anterior, por isso `atual`)."""
limite_nome = _limite_coluna_nome(linhas)
beneficiarios: List[_Beneficiario] = []
for linha in linhas:
tokens = _tokens(linha)
certif = next((t for t in tokens if _CERTIF_RE.match(t)), None)
valores = [_valor_para_float(t) for t in tokens if _MONEY_RE.match(t)]
if certif is None and (not valores or atual is None):
continue # cabeçalho da tabela ou texto solto, sem lançamento
meses = [t for t in tokens if _MES_ANO_RE.match(t)]
if len(valores) != 2 or len(meses) != 1:
raise ValueError(
f"Bradesco Saúde: linha de lançamento em formato inesperado "
f"({' '.join(tokens)!r}) — esperava 1 Mês/Ano e 2 valores (Valor e "
f"Part. Seg.). Revisar manualmente antes de confiar no resultado."
)
if certif is not None:
nome = _nome_da_linha(linha, certif, limite_nome)
if not nome:
raise ValueError(
f"Bradesco Saúde: Certif. {certif} sem 'Nome Segurado' legível "
f"({' '.join(tokens)!r}) — revisar manualmente antes de confiar no resultado."
)
atual = _Beneficiario(certif=certif, nome=nome)
beneficiarios.append(atual)
mov = next((t for t in tokens if t in _MOVIMENTOS), "")
atual.lancamentos.append((meses[0], mov, valores[0], valores[1]))
return beneficiarios
def _le_resumo(linhas: List[List[_Palavra]]) -> Optional[Tuple[float, float, int]]:
"""(Valor, Part. Seg., nº de lançamentos) da linha "(TS)TOTAIS DA
SUBFATURA" do quadro "Resumo", ou None se a tabela não for o Resumo."""
for linha in linhas:
tokens = _tokens(linha)
if not tokens or not tokens[0].startswith("(TS)"):
continue
valores = [_valor_para_float(t) for t in tokens if _MONEY_RE.match(t)]
inteiros = [int(t) for t in tokens if _INTEIRO_RE.match(t)]
if len(valores) != 2 or len(inteiros) != 4:
raise ValueError(
f"Bradesco Saúde: linha '(TS)TOTAIS DA SUBFATURA' do Resumo em formato "
f"inesperado ({' '.join(tokens)!r}) — revisar manualmente."
)
return valores[0], valores[1], inteiros[3]
return None
def _confere_com_resumo(
beneficiarios: List[_Beneficiario], resumos: List[Tuple[float, float, int]]
) -> None:
"""Soma extraída x "(TS)TOTAIS DA SUBFATURA" impresso na fatura (somando
as subfaturas, se houver mais de uma) — ver particularidade 6."""
if not resumos:
raise ValueError(
"Bradesco Saúde: quadro 'Resumo' ((TS)TOTAIS DA SUBFATURA) não encontrado "
"— sem ele não há como conferir a extração. Revisar manualmente."
)
lancamentos = [lc for b in beneficiarios for lc in b.lancamentos]
valor = round(sum(lc[2] for lc in lancamentos), 2)
part_seg = round(sum(lc[3] for lc in lancamentos), 2)
esperado_valor = round(sum(r[0] for r in resumos), 2)
esperado_part_seg = round(sum(r[1] for r in resumos), 2)
esperado_qtd = sum(r[2] for r in resumos)
if (valor, part_seg, len(lancamentos)) != (esperado_valor, esperado_part_seg, esperado_qtd):
raise ValueError(
f"Bradesco Saúde: extração não bate com o Resumo da fatura — extraído "
f"{len(lancamentos)} lançamentos / Valor {valor:.2f} / Part. Seg. {part_seg:.2f}, "
f"impresso {esperado_qtd} lançamentos / Valor {esperado_valor:.2f} / Part. Seg. "
f"{esperado_part_seg:.2f}. Revisar manualmente antes de confiar no resultado."
) )
return tokens
class BradescoSaude(OperadoraParser): class BradescoSaude(OperadoraParser):
nome_operadora = "BRADESCO SAÚDE" nome_operadora = "BRADESCO SAÚDE"
chave_casamento = "nome" # PDF não traz CPF, só "Certif." por pessoa chave_casamento = "nome" # PDF não traz CPF, só "Certif." por pessoa
def _extrai_linhas_beneficiarios(self, caminho_pdf: str) -> List[Dict[str, object]]: def _extrai_beneficiarios(self, caminho_pdf: str) -> List[_Beneficiario]:
resultado = _converter().convert(caminho_pdf) resultado = _converter().convert(caminho_pdf)
beneficiarios: List[Dict[str, object]] = [] beneficiarios: List[_Beneficiario] = []
resumos: List[Tuple[float, float, int]] = []
for table in resultado.document.tables: for palavras in _palavras_por_tabela(resultado):
if not _tabela_eh_de_beneficiarios(table): linhas = _agrupa_linhas(palavras)
continue resumo = _le_resumo(linhas)
if resumo is not None:
linhas_ordenadas, linhas = _agrupa_por_linha(table) resumos.append(resumo)
if not linhas_ordenadas: elif any(normaliza_nome(p.texto).startswith("CERTIF") for p in palavras):
continue atual = beneficiarios[-1] if beneficiarios else None
beneficiarios.extend(_le_beneficiarios(linhas, atual))
valores = _extrai_valores_area(linhas_ordenadas, linhas, _BUCKET_VALOR_AREA)
part_segs = _extrai_valores_area(linhas_ordenadas, linhas, _BUCKET_PART_SEG)
for posicao, idx in enumerate(linhas_ordenadas):
certif_texto = " ".join(linhas[idx].get(_BUCKET_CERTIF, [])).strip()
nome_texto = " ".join(linhas[idx].get(_BUCKET_NOME, [])).strip()
m_certif = _CERTIF_RE.match(certif_texto)
if not m_certif or not nome_texto:
raise ValueError(
f"Bradesco Saúde: linha com 'Certif.'/'Nome Segurado' em formato "
f"inesperado (Certif.={certif_texto!r}, Nome={nome_texto!r}) — "
f"revisar manualmente antes de confiar no resultado."
)
familia = m_certif.group("familia")
titular = m_certif.group("sufixo") == "00"
beneficiarios.append({
"numero_beneficiario": certif_texto,
"nome": nome_texto,
"tipo": "T" if titular else "D",
"numero_titular": None if titular else f"{familia}/00",
"valor": valores[posicao],
"part_seg": part_segs[posicao],
})
_confere_com_resumo(beneficiarios, resumos)
return beneficiarios return beneficiarios
def _monta_lancamentos(self, beneficiarios: List[Dict[str, object]]) -> List[Lancamento]: def _monta_lancamentos(self, beneficiarios: List[_Beneficiario]) -> List[Lancamento]:
lancamentos: List[Lancamento] = [] lancamentos: List[Lancamento] = []
for b in beneficiarios: for b in beneficiarios:
lancamentos.append(Lancamento( m_certif = _CERTIF_RE.match(b.certif)
numero_beneficiario=b["numero_beneficiario"], titular = m_certif.group("sufixo") == "00"
nome=b["nome"], tipo = "T" if titular else "D"
cpf="", numero_titular = None if titular else f"{m_certif.group('familia')}/00"
tipo=b["tipo"], for mes_ano, mov, valor, part_seg in b.lancamentos:
rubrica="Mensalidade", sufixo_rubrica = f" {mes_ano}" + (f" ({mov})" if mov else "")
valor=b["valor"],
tipo_lancamento="mensalidade",
numero_titular=b["numero_titular"],
))
# ver particularidade 5: sem lançamento quando o valor é zero,
# mesmo padrão já usado pela Itamed para ausência de coparticipação.
if b["part_seg"] != 0.0:
lancamentos.append(Lancamento( lancamentos.append(Lancamento(
numero_beneficiario=b["numero_beneficiario"], numero_beneficiario=b.certif,
nome=b["nome"], nome=b.nome,
cpf="", cpf="",
tipo=b["tipo"], tipo=tipo,
rubrica="Coparticipação", rubrica="Mensalidade" + sufixo_rubrica,
valor=b["part_seg"], valor=valor,
tipo_lancamento="coparticipacao", tipo_lancamento="mensalidade",
numero_titular=b["numero_titular"], numero_titular=numero_titular,
)) ))
# ver particularidade 5: sem lançamento quando o valor é zero,
# mesmo padrão já usado pela Itamed para ausência de coparticipação.
if part_seg != 0.0:
lancamentos.append(Lancamento(
numero_beneficiario=b.certif,
nome=b.nome,
cpf="",
tipo=tipo,
rubrica="Coparticipação" + sufixo_rubrica,
valor=part_seg,
tipo_lancamento="coparticipacao",
numero_titular=numero_titular,
))
return lancamentos return lancamentos
def _agrega_por_individuo_e_tipo(self, lancamentos: List[Lancamento]) -> List[Individuo]: def _agrega_por_individuo_e_tipo(self, lancamentos: List[Lancamento]) -> List[Individuo]:
@ -288,7 +407,7 @@ class BradescoSaude(OperadoraParser):
return [individuos[c] for c in ordem] return [individuos[c] for c in ordem]
def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]: def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]:
beneficiarios = self._extrai_linhas_beneficiarios(caminho_arquivo) beneficiarios = self._extrai_beneficiarios(caminho_arquivo)
lancamentos = self._monta_lancamentos(beneficiarios) lancamentos = self._monta_lancamentos(beneficiarios)
individuos = self._agrega_por_individuo_e_tipo(lancamentos) individuos = self._agrega_por_individuo_e_tipo(lancamentos)
return individuos, [] # Bradesco não gera itens de auditoria na extração return individuos, [] # Bradesco não gera itens de auditoria na extração

View File

@ -956,7 +956,6 @@ button.conc-kpi.is-active {
color: var(--text-secondary); color: var(--text-secondary);
} }
.conc-situacao--vencido,
.conc-situacao--nao_vinculado { .conc-situacao--nao_vinculado {
color: var(--gold); color: var(--gold);
} }