Correção operadora Odonto Uni

This commit is contained in:
Gabriel 2026-08-27 08:55:01 -03:00
parent 15f53faa33
commit 04172a9945
4 changed files with 121 additions and 41 deletions

View File

@ -41,7 +41,7 @@ A ferramenta deixou de ser "a automação da TECNOMYL", hoje atende várias empr
|---|---|---|---| |---|---|---|---|
| `unimed_saude` (5060, CSV ou 2 PDFs) | nome (mensalidade), CPF (coparticipação em PDF) | 8 | Sim (empresas 1123, 221, mais testes antigos) | | `unimed_saude` (5060, CSV ou 2 PDFs) | nome (mensalidade), CPF (coparticipação em PDF) | 8 | Sim (empresas 1123, 221, mais testes antigos) |
| `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" | | `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) |
| `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** |

10
.gitignore vendored
View File

@ -22,6 +22,16 @@ db.sqlite3-journal
*.log *.log
local_settings.py local_settings.py
# Uploads de teste (base local) — planilhas anexadas só pra processar uma
# importação/apuração, não são conteúdo permanente do app (diferente de
# media/links_ferramentas/, que são ícones de verdade usados na UI).
# Importações/apurações reais são feitas direto na base de produção, nunca
# sincronizadas via git.
media/planos_saude/operadora/
media/planos_saude/planilha_padrao/
media/indicadores/honorarios/
media/indicadores/tareffa/
# Distribuição / empacotamento # Distribuição / empacotamento
build/ build/
dist/ dist/

View File

@ -251,6 +251,14 @@ Usuário reportou, testando a correção acima pela tela: depois de editar Valor
Aproveitando a mesma conversa, usuário pediu que as células de Valor/Valor Empresa aceitem uma expressão de soma/subtração digitada direto (ex.: `"15,30-15"` pra abater R$15 do valor, sem precisar calcular fora e digitar o resultado pronto). Implementado client-side: `pidAvaliaExpressaoValorMonetario()` reconhece números em formato BR (vírgula decimal, ponto de milhar opcional) separados por `+`/`-` e, se o texto digitado for uma expressão válida (sobra zero caractere não reconhecido), substitui o campo pelo resultado já calculado antes do PATCH — um valor negativo isolado (ex.: `"-15,30"`) continua sendo só um número, não uma expressão (só conta como expressão se houver operador depois do primeiro caractere). O backend não teve nenhuma mudança — `valor`/`valor_empresa` continuam sendo `CharField` sem validação de formato, então já aceitava (e continua aceitando) qualquer string; a expressão nunca chega até lá, só o resultado. Aproveitando a mesma conversa, usuário pediu que as células de Valor/Valor Empresa aceitem uma expressão de soma/subtração digitada direto (ex.: `"15,30-15"` pra abater R$15 do valor, sem precisar calcular fora e digitar o resultado pronto). Implementado client-side: `pidAvaliaExpressaoValorMonetario()` reconhece números em formato BR (vírgula decimal, ponto de milhar opcional) separados por `+`/`-` e, se o texto digitado for uma expressão válida (sobra zero caractere não reconhecido), substitui o campo pelo resultado já calculado antes do PATCH — um valor negativo isolado (ex.: `"-15,30"`) continua sendo só um número, não uma expressão (só conta como expressão se houver operador depois do primeiro caractere). O backend não teve nenhuma mudança — `valor`/`valor_empresa` continuam sendo `CharField` sem validação de formato, então já aceitava (e continua aceitando) qualquer string; a expressão nunca chega até lá, só o resultado.
### Bug real — Dental Uni Odonto: segundo layout de relatório (sem colchete no Nº Cartão) travava a extração inteira
Usuário reportou (empresa 503, "TAROBA CONSTRUCOES LTDA", dois arquivos "503"/"503-2" — um por contrato/filial, 774977 e 785666) que os dois arquivos davam "Nenhum beneficiário foi encontrado neste arquivo" na pré-validação. Investigado rodando `pdfplumber` contra os dois PDFs reais: o relatório "Relatório de Beneficiários" da Dental Uni tem uma segunda variante de renderização, sem o `[Nº Cartão]` entre colchetes que o parser exigia desde a Rodada 49 — o número vem solto, colado direto depois do nome ("774977 ALEX PATRICIO VISOLI 00202577667800001301 25/04/1991 09/11/2023 0,00 16,57 33,14") — e, nessa variante, titular e dependente têm a MESMA indentação (a heurística de indentação da Rodada 49 não se aplica).
Corrigido acrescentando um segundo regex de linha (`_LINHA_SEM_COLCHETE_RE`, tentado só quando o formato com colchete não bate) em `operadoras/dental_uni/odonto_mensalidade.py`: como a indentação não ajuda nesse layout, titular/dependente passou a ser decidido pela presença da coluna "Total Fam" (só preenchida na linha do titular, mesma regra de negócio já documentada desde a Rodada 49, só que agora usada como sinal em vez de só ser ignorada) — 3 valores monetários na linha = titular, 2 = dependente. O valor do beneficiário é sempre o 2º valor monetário (a coluna "Tx Inc." vem sempre impressa, mesmo "0,00", antes de "Valor Unit"). Validado rodando `extrai()` de ponta a ponta contra os dois arquivos reais: 24 beneficiários/R$397,68 e 10 beneficiários/R$165,70, batendo exatamente com os totais impressos em cada relatório, incluindo nomes quebrados em duas linhas reconstituídos certos (mesma lógica da Rodada 50, sem mudança). O formato original com colchete (empresa 1084) foi reconfirmado sem regressão com um teste dedicado.
O fato de a operadora ter mandado um arquivo por contrato/filial não teve nenhuma relação com o erro — múltiplos arquivos de operadora por importação já são suportados desde a Rodada 83 (mesclados automaticamente).
### Bug real — código de cadastro da Dental Uni Odonto estava errado (1723 → 4723) ### Bug real — código de cadastro da Dental Uni Odonto estava errado (1723 → 4723)
Usuário avisou que o código de cadastro da operadora "Dental Uni Odonto" no Questor está registrado errado desde a Rodada 49 (`1723`); o código correto é `4723` — confirmado batendo com os arquivos reais de planilha padrão já salvos no sistema (nomes de arquivo trazem `OPER_4723_DENTAL_UNI...`). Corrigido `OPERADORAS["dental_uni_odonto_mensalidade"]["codigo_operadora"]` em `pipeline.py`; o label de exibição (`"4723 - Dental Uni Odonto"`) é derivado desse campo em todo lugar que usa `label_operadora()`/`lista_operadoras()` (combobox de operadora, mensagens de erro, `nome_operadora` de importação), então a correção já se propaga sozinha sem precisar tocar em mais nada. Registros já persistidos de importações antigas (`ImportacaoPlanoSaude.nome_operadora`, texto congelado no momento da criação) não são retroativamente corrigidos. Usuário avisou que o código de cadastro da operadora "Dental Uni Odonto" no Questor está registrado errado desde a Rodada 49 (`1723`); o código correto é `4723` — confirmado batendo com os arquivos reais de planilha padrão já salvos no sistema (nomes de arquivo trazem `OPER_4723_DENTAL_UNI...`). Corrigido `OPERADORAS["dental_uni_odonto_mensalidade"]["codigo_operadora"]` em `pipeline.py`; o label de exibição (`"4723 - Dental Uni Odonto"`) é derivado desse campo em todo lugar que usa `label_operadora()`/`lista_operadoras()` (combobox de operadora, mensagens de erro, `nome_operadora` de importação), então a correção já se propaga sozinha sem precisar tocar em mais nada. Registros já persistidos de importações antigas (`ImportacaoPlanoSaude.nome_operadora`, texto congelado no momento da criação) não são retroativamente corrigidos.

View File

@ -55,6 +55,30 @@ outro caso de nome desencontrado numa importação real (aba Auditoria —
"Vincular pessoa"), veja se o nome extraído está com um pedaço a mais/a "Vincular pessoa"), veja se o nome extraído está com um pedaço a mais/a
menos de algum beneficiário vizinho antes de assumir que é uma divergência menos de algum beneficiário vizinho antes de assumir que é uma divergência
de cadastro de verdade. de cadastro de verdade.
Segundo layout descoberto (empresa 503, contratos Dental Uni 774977/785666,
"TAROBA CONSTRUCOES LTDA"): o mesmo relatório "Relatório de Beneficiários"
pode vir SEM colchete nenhum no Nº Cartão — o número fica solto, colado
direto depois do nome ("774977 ALEX PATRICIO VISOLI 00202577667800001301
25/04/1991 09/11/2023 0,00 16,57 33,14"), e titular/dependente têm
exatamente a MESMA indentação (a heurística de indentação da particularidade
1 não funciona aqui). O parser tenta primeiro o formato com colchete
(`_LINHA_COLCHETE_RE`, layout original) e, se não bater, tenta este segundo
formato (`_LINHA_SEM_COLCHETE_RE`) — os dois convivem no mesmo parser porque
são a mesma operadora/relatório, só uma variação de renderização.
Nesse segundo formato, a linha tem: "<Contrato> <Nome> <Nº Cartão sem
colchete, 10+ dígitos> <Nasc.> <Inclusão> [Exclusão] <Tx Inc.> <Valor Unit>
[Total Fam]". Como "Tx Inc." aparece sempre impresso (mesmo "0,00") antes de
"Valor Unit", o valor do beneficiário é sempre o SEGUNDO número monetário
encontrado depois do cartão, não o primeiro (diferença deliberada em
relação ao formato com colchete, que não tem essa coluna "Tx Inc." antes do
valor). Como no formato original, "Total Fam" só vem preenchido na linha do
titular (soma da família) — só que aqui essa é a única forma confiável de
saber se a linha é titular ou dependente (3 valores monetários = titular,
2 = dependente), já que a indentação não ajuda. "Tx Inc." é ignorada, mesmo
espírito de "Total Fam" ser ignorada — nenhuma das duas é o valor a
custear/descontar do beneficiário.
""" """
import re import re
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
@ -64,9 +88,14 @@ import pdfplumber
from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento
from portal_api.planos_saude.operadoras.base import OperadoraParser from portal_api.planos_saude.operadoras.base import OperadoraParser
_LINHA_RE = re.compile( _LINHA_COLCHETE_RE = re.compile(
r'^(?P<indent>\s*)(?P<nome_parcial>[^\[\]]*?)\s*\[(?P<cartao>\d+)\]\s*(?P<resto>.*)$' r'^(?P<indent>\s*)(?P<nome_parcial>[^\[\]]*?)\s*\[(?P<cartao>\d+)\]\s*(?P<resto>.*)$'
) )
# Segundo layout (sem colchete no Nº Cartão, ver docstring do módulo) —
# "<Contrato> <Nome> <Cartão solto> <resto: datas + Tx Inc./Valor Unit/Total Fam>".
_LINHA_SEM_COLCHETE_RE = re.compile(
r"^(?P<indent>\s*)\d+\s+(?P<nome_parcial>[A-ZÀ-Ý][A-ZÀ-Ý '.-]*?)\s+(?P<cartao>\d{10,})\s+(?P<resto>.*)$"
)
# Nomes no relatório vêm em CAIXA ALTA — usado pra distinguir uma linha de # Nomes no relatório vêm em CAIXA ALTA — usado pra distinguir uma linha de
# nome "órfã" (continuação de um nome quebrado em duas linhas) de qualquer # nome "órfã" (continuação de um nome quebrado em duas linhas) de qualquer
# outro texto do PDF (cabeçalho/rodapé/totais), que nunca vem 100% maiúsculo. # outro texto do PDF (cabeçalho/rodapé/totais), que nunca vem 100% maiúsculo.
@ -98,24 +127,14 @@ class DentalUniOdontoMensalidade(OperadoraParser):
titular_cartao_atual: Optional[str] = None titular_cartao_atual: Optional[str] = None
for linha in linhas: for linha in linhas:
m = _LINHA_RE.match(linha) m = _LINHA_COLCHETE_RE.match(linha)
if not m: if m:
# Sem "[Nº Cartão]" nesta linha — ou é ruído (cabeçalho,
# totais) ou é o excedente de um nome que quebrou em duas
# linhas (ver particularidade 2 no docstring do módulo):
# nesse caso, pertence ao ÚLTIMO lançamento já adicionado,
# nunca ao próximo.
fragmento = linha.strip()
if fragmento and _NOME_FRAGMENTO_RE.match(fragmento) and lancamentos:
lancamentos[-1].nome = f"{lancamentos[-1].nome} {fragmento}"
continue
nome = m.group("nome_parcial").strip() nome = m.group("nome_parcial").strip()
cartao = m.group("cartao") cartao = m.group("cartao")
valores = _VALOR_RE.findall(m.group("resto")) valores = _VALOR_RE.findall(m.group("resto"))
if not nome or not valores: if not nome or not valores:
# Linha com colchetes mas sem nome+valor de beneficiário de # Linha com colchetes mas sem nome+valor de beneficiário
# verdade (ex: "Cliente: (...) CNPJ: [19210328000160]"). # de verdade (ex: "Cliente: (...) CNPJ: [19210328000160]").
continue continue
indent = len(m.group("indent")) indent = len(m.group("indent"))
@ -141,6 +160,49 @@ class DentalUniOdontoMensalidade(OperadoraParser):
tipo_lancamento="mensalidade", tipo_lancamento="mensalidade",
numero_titular=numero_titular, numero_titular=numero_titular,
)) ))
continue
m = _LINHA_SEM_COLCHETE_RE.match(linha)
if m:
nome = m.group("nome_parcial").strip()
cartao = m.group("cartao")
valores = _VALOR_RE.findall(m.group("resto"))
if not nome or len(valores) < 2:
continue
# Sem colchete, a indentação não distingue titular de
# dependente (ver docstring do módulo) — usamos a presença
# de "Total Fam" (3º valor, só preenchido no titular) em vez
# disso. O valor do beneficiário é sempre o 2º valor
# ("Valor Unit"), já que "Tx Inc." vem sempre impresso antes.
if len(valores) >= 3:
tipo = "T"
titular_cartao_atual = cartao
numero_titular = None
else:
tipo = "D"
numero_titular = titular_cartao_atual
lancamentos.append(Lancamento(
numero_beneficiario=cartao,
nome=nome,
cpf="",
tipo=tipo,
rubrica="Mensalidade",
valor=_valor_para_float(valores[1]),
tipo_lancamento="mensalidade",
numero_titular=numero_titular,
))
continue
# Nenhum dos dois formatos bateu — ou é ruído (cabeçalho,
# totais) ou é o excedente de um nome que quebrou em duas
# linhas (ver particularidade 2 no docstring do módulo): nesse
# caso, pertence ao ÚLTIMO lançamento já adicionado, nunca ao
# próximo.
fragmento = linha.strip()
if fragmento and _NOME_FRAGMENTO_RE.match(fragmento) and lancamentos:
lancamentos[-1].nome = f"{lancamentos[-1].nome} {fragmento}"
return lancamentos return lancamentos
def _agrega_por_individuo(self, lancamentos: List[Lancamento]) -> List[Individuo]: def _agrega_por_individuo(self, lancamentos: List[Lancamento]) -> List[Individuo]: