From 04172a9945b8b4b7a01f1566dcdaf4b838c24377 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 27 Aug 2026 08:55:01 -0300 Subject: [PATCH] =?UTF-8?q?Corre=C3=A7=C3=A3o=20operadora=20Odonto=20Uni?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../importacao-questor-plano-saude/SKILL.md | 2 +- .gitignore | 10 ++ portal_api/planos_saude/CHANGELOG.md | 8 + .../dental_uni/odonto_mensalidade.py | 142 +++++++++++++----- 4 files changed, 121 insertions(+), 41 deletions(-) diff --git a/.claude/skills/importacao-questor-plano-saude/SKILL.md b/.claude/skills/importacao-questor-plano-saude/SKILL.md index f343545..c310448 100644 --- a/.claude/skills/importacao-questor-plano-saude/SKILL.md +++ b/.claude/skills/importacao-questor-plano-saude/SKILL.md @@ -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** | diff --git a/.gitignore b/.gitignore index 4d793fd..e85e880 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/portal_api/planos_saude/CHANGELOG.md b/portal_api/planos_saude/CHANGELOG.md index f04a12f..06bbea7 100644 --- a/portal_api/planos_saude/CHANGELOG.md +++ b/portal_api/planos_saude/CHANGELOG.md @@ -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. diff --git a/portal_api/planos_saude/operadoras/dental_uni/odonto_mensalidade.py b/portal_api/planos_saude/operadoras/dental_uni/odonto_mensalidade.py index 282bc1e..ce8e6a3 100644 --- a/portal_api/planos_saude/operadoras/dental_uni/odonto_mensalidade.py +++ b/portal_api/planos_saude/operadoras/dental_uni/odonto_mensalidade.py @@ -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: " [Exclusão] +[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\s*)(?P[^\[\]]*?)\s*\[(?P\d+)\]\s*(?P.*)$' ) +# Segundo layout (sem colchete no Nº Cartão, ver docstring do módulo) — +# " ". +_LINHA_SEM_COLCHETE_RE = re.compile( + r"^(?P\s*)\d+\s+(?P[A-ZÀ-Ý][A-ZÀ-Ý '.-]*?)\s+(?P\d{10,})\s+(?P.*)$" +) # 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,49 +127,82 @@ 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}" + 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]"). + continue + + indent = len(m.group("indent")) + if indent_base is None: + # A 1ª linha de beneficiário do arquivo é sempre um titular. + indent_base = indent + + if indent <= indent_base: + 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[0]), + tipo_lancamento="mensalidade", + numero_titular=numero_titular, + )) continue - 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]"). + 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 - indent = len(m.group("indent")) - if indent_base is None: - # A 1ª linha de beneficiário do arquivo é sempre um titular. - indent_base = indent - - if indent <= indent_base: - 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[0]), - tipo_lancamento="mensalidade", - numero_titular=numero_titular, - )) + # 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]: