Correção operadora Odonto Uni
This commit is contained in:
parent
15f53faa33
commit
04172a9945
@ -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) |
|
||||
| `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) |
|
||||
| `bradesco_saude` (1386) | nome | 1 | Sim (empresa 221) |
|
||||
| `bradesco_dental_odonto_mensalidade` (3759) | nome | 1 | Sim (empresa 1684). **Ver ressalva abaixo** |
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -22,6 +22,16 @@ db.sqlite3-journal
|
||||
*.log
|
||||
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
|
||||
build/
|
||||
dist/
|
||||
|
||||
@ -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.
|
||||
|
||||
### 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)
|
||||
|
||||
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.
|
||||
|
||||
@ -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
|
||||
menos de algum beneficiário vizinho antes de assumir que é uma divergência
|
||||
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
|
||||
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.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>.*)$'
|
||||
)
|
||||
# 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
|
||||
# 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.
|
||||
@ -98,24 +127,14 @@ class DentalUniOdontoMensalidade(OperadoraParser):
|
||||
titular_cartao_atual: Optional[str] = None
|
||||
|
||||
for linha in linhas:
|
||||
m = _LINHA_RE.match(linha)
|
||||
if not 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
|
||||
|
||||
m = _LINHA_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 not valores:
|
||||
# Linha com colchetes mas sem nome+valor de beneficiário de
|
||||
# verdade (ex: "Cliente: (...) CNPJ: [19210328000160]").
|
||||
# Linha com colchetes mas sem nome+valor de beneficiário
|
||||
# de verdade (ex: "Cliente: (...) CNPJ: [19210328000160]").
|
||||
continue
|
||||
|
||||
indent = len(m.group("indent"))
|
||||
@ -141,6 +160,49 @@ class DentalUniOdontoMensalidade(OperadoraParser):
|
||||
tipo_lancamento="mensalidade",
|
||||
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
|
||||
|
||||
def _agrega_por_individuo(self, lancamentos: List[Lancamento]) -> List[Individuo]:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user