From 39ea8018992844dac75118d0dcf044b10255d90f Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 17 Sep 2026 17:10:57 -0300 Subject: [PATCH] =?UTF-8?q?Implementa=C3=A7=C3=A3o=20das=20regras=20da=20T?= =?UTF-8?q?ecnomyl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 11 +- ...osaudedepaulalinha_tipo_pessoa_and_more.py | 23 ++++ portal_api/models.py | 20 ++++ portal_api/planos_saude/CHANGELOG.md | 14 +++ portal_api/planos_saude/CLAUDE.md | 14 ++- portal_api/planos_saude/matcher.py | 9 ++ portal_api/planos_saude/modelos.py | 10 ++ .../operadoras/amil/odonto_mensalidade.py | 106 ++++++++++++++++-- portal_api/planos_saude/regras_empresa.py | 48 ++++++++ portal_api/views.py | 17 ++- 10 files changed, 258 insertions(+), 14 deletions(-) create mode 100644 portal_api/migrations/0074_importacaoplanosaudedepaulalinha_tipo_pessoa_and_more.py diff --git a/.claude/settings.json b/.claude/settings.json index 0c0ae79..83533e5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -71,7 +71,16 @@ "Bash(curl -s http://127.0.0.1:8000/static/css/halloween-cobweb.css)", "Bash(\"/c/Users/Depaula/Documents/Portal/.venv/Scripts/python.exe\" _scratch_insert_seasonal_toggle.py)", "Bash(rm _scratch_insert_seasonal_toggle.py)", - "Bash(sort -t' ' -k2 -n -u)" + "Bash(sort -t' ' -k2 -n -u)", + "Bash(./.venv/Scripts/python.exe -c ' *)", + "Bash(\"./.venv/Scripts/python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\75800899-5860-4fc9-a89a-41027f847563\\\\scratchpad\\\\check_tecnomyl.py\")", + "Bash(\"./.venv/Scripts/python.exe\" -c \"import xlrd; print\\(xlrd.__version__\\)\")", + "Bash(\"./.venv/Scripts/pip.exe\" list *)", + "Bash(\"./.venv/Scripts/python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\75800899-5860-4fc9-a89a-41027f847563\\\\scratchpad\\\\check_amil_898.py\")", + "Bash(\"./.venv/Scripts/python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\75800899-5860-4fc9-a89a-41027f847563\\\\scratchpad\\\\check_amil_pipeline_full.py\")", + "Bash(\"./.venv/Scripts/python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\75800899-5860-4fc9-a89a-41027f847563\\\\scratchpad\\\\check_dup_cpf.py\")", + "Bash(\"./.venv/Scripts/python.exe\" manage.py makemigrations --check --dry-run)", + "Bash(\"./.venv/Scripts/pip.exe\" install *)" ] } } diff --git a/portal_api/migrations/0074_importacaoplanosaudedepaulalinha_tipo_pessoa_and_more.py b/portal_api/migrations/0074_importacaoplanosaudedepaulalinha_tipo_pessoa_and_more.py new file mode 100644 index 0000000..7f667f4 --- /dev/null +++ b/portal_api/migrations/0074_importacaoplanosaudedepaulalinha_tipo_pessoa_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.7 on 2026-09-17 17:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('portal_api', '0073_contabil_observacao_chave_conta_descricao'), + ] + + operations = [ + migrations.AddField( + model_name='importacaoplanosaudedepaulalinha', + name='tipo_pessoa', + field=models.CharField(blank=True, help_text='Titular/Dependente/Agregado, gravado pelo matcher a partir do arquivo da operadora — usado só por regras de empresa que precisam distinguir dependente de agregado (ex.: Amil/Tecnomyl). Ver planos_saude.modelos.LinhaSistema.', max_length=1, verbose_name='Tipo de pessoa (T/D/A)'), + ), + migrations.AddField( + model_name='importacaoplanosaudelinha', + name='tipo_pessoa', + field=models.CharField(blank=True, help_text='Titular/Dependente/Agregado, gravado pelo matcher a partir do arquivo da operadora — usado só por regras de empresa que precisam distinguir dependente de agregado (ex.: Amil/Tecnomyl). Ver planos_saude.modelos.LinhaSistema.', max_length=1, verbose_name='Tipo de pessoa (T/D/A)'), + ), + ] diff --git a/portal_api/models.py b/portal_api/models.py index bdc0fa6..b6ed41e 100644 --- a/portal_api/models.py +++ b/portal_api/models.py @@ -711,6 +711,16 @@ class ImportacaoPlanoSaudeLinha(models.Model): ) descricao = models.CharField("Descrição", max_length=255, blank=True) ordem = models.PositiveIntegerField("Ordem", default=0) + tipo_pessoa = models.CharField( + "Tipo de pessoa (T/D/A)", + max_length=1, + blank=True, + help_text=( + "Titular/Dependente/Agregado, gravado pelo matcher a partir do arquivo da " + "operadora — usado só por regras de empresa que precisam distinguir " + "dependente de agregado (ex.: Amil/Tecnomyl). Ver planos_saude.modelos.LinhaSistema." + ), + ) class Meta: verbose_name = "Linha de importação de plano de saúde" @@ -1063,6 +1073,16 @@ class ImportacaoPlanoSaudeDePaulaLinha(models.Model): ) descricao = models.CharField("Descrição", max_length=255, blank=True) ordem = models.PositiveIntegerField("Ordem", default=0) + tipo_pessoa = models.CharField( + "Tipo de pessoa (T/D/A)", + max_length=1, + blank=True, + help_text=( + "Titular/Dependente/Agregado, gravado pelo matcher a partir do arquivo da " + "operadora — usado só por regras de empresa que precisam distinguir " + "dependente de agregado (ex.: Amil/Tecnomyl). Ver planos_saude.modelos.LinhaSistema." + ), + ) class Meta: verbose_name = "Linha de importação de plano de saúde - De Paula" diff --git a/portal_api/planos_saude/CHANGELOG.md b/portal_api/planos_saude/CHANGELOG.md index d6690d7..26674bb 100644 --- a/portal_api/planos_saude/CHANGELOG.md +++ b/portal_api/planos_saude/CHANGELOG.md @@ -344,3 +344,17 @@ Pedido do usuário (2026-09-16), com print da tela de Revisão mostrando um espa - **`renderReviewResumoCusteio(importacao)`** (`importacao-plano-saude.js`, nova) monta o resumo a partir dos campos já presentes na importação (`tipos_lancamento`/`custeio_por_tipo`/`regra_empresa`/`regra_empresa_label`) — **não** reaproveita `renderResumoRegra()`/`regrasEmpresaCache` de "Nova Importação" porque essa cache só é buscada ao abrir os modais de "Nova Importação"/"Cadastro de Regras", ficando vazia quando a Revisão é aberta direto do histórico. Em vez disso, `reviewResumoTextoTipo()` detecta cobertura por "regra especial da empresa" olhando se `custeio_por_tipo[tipo]` veio vazio (sem `titular`/`dependente`) com `regra_empresa` preenchido — o mesmo sinal que `ImportacaoPlanoSaudeCreateSerializer.validate()` já grava pra tipos cobertos por uma regra empresa (ver "zeram custeio_por_tipo[tipo] só pros tipos em regra_empresa_tipos" no `CLAUDE.md` deste pacote) — evitando uma segunda fonte de verdade sobre quais tipos a regra cobre. - `abrirRevisao()` chama essa função e decide se o bloco inteiro fica visível (`temResumo || temObs`) — antes só aparecia com observação cadastrada, agora aparece por padrão em toda importação com tipo de lançamento selecionado. - Sem migração, sem endpoint novo — só leitura de campos que a Revisão já carregava. + +### Rodada 132 — Regra de empresa da Amil (898) na Tecnomyl + suporte a Excel + `tipo_pessoa` + +Depois de validar a regra da Unimed já cadastrada pra Tecnomyl (empresa 1778, ver `CHANGELOG.md` de rodadas anteriores/`CLAUDE.md`), o usuário passou a regra da segunda operadora, Amil Odonto (898): a empresa custeia 100% da mensalidade de titular e dependente direto; agregados (avós, tios, sobrinhos, sogros) pagam 100%, no mesmo valor por pessoa. O arquivo real fornecido (`Demonstrativo de faturamento Tecnomyl 08.2026.xlsx`, competência 08/2026) revelou dois problemas que precisaram ser resolvidos antes da regra em si fazer sentido: + +1. **O arquivo real é Excel, não PDF** — o parser Amil Odonto só lia PDF até aqui. `AmilOdontoMensalidade.extrai()` (`operadoras/amil/odonto_mensalidade.py`) passou a detectar a extensão (`.xlsx`/`.xlsm` → novo caminho via `openpyxl`; qualquer outra → o caminho PDF já existente, inalterado), mesmo padrão de detecção por formato já usado pela Unimed Saúde. `_localiza_cabecalho_xlsx` acha a linha de cabeçalho pelo NOME das colunas (`Código`/`Beneficiário`/`CPF`/`Tipo`/`Mensalidade`), não por posição fixa, já que há um número variável de linhas de título/contrato/fatura acima da tabela real. Validado rodando `extrai()` de ponta a ponta contra o arquivo real: 475 beneficiários (217 T/220 D/38 A), cada um pagando um valor fixo por plano (R$ 20,81 no "DENTAL E200 R PJ_PROT", R$ 16,01 no "DENTAL 200 R PJ_DOC") — e a coluna "Tipo" do relatório batendo 1:1 com os exemplos de "agregado" repassados pelo cliente (avós, sogros, sobrinhos, pai/mãe, irmãos = 'A'; cônjuge/filho(a) = 'D'). +2. **A planilha padrão do sistema não distingue dependente de agregado** — só sabe titular/não-titular (via `nome_dependente`/`cpf_dependente` vazios ou não), então a regra de custeio (que precisa diferenciar 'D' de 'A' por pessoa) não tinha de onde ler essa informação numa `LinhaSistema`/`ImportacaoPlanoSaudeLinha` já casada. Resolvido com um campo novo, `tipo_pessoa` ('T'/'D'/'A'): + - `LinhaSistema.tipo_pessoa` (`modelos.py`, dataclass, default `""`). + - `ImportacaoPlanoSaudeLinha.tipo_pessoa`/`ImportacaoPlanoSaudeDePaulaLinha.tipo_pessoa` (`CharField(max_length=1, blank=True)`, migração `0074`, aditiva — sem backfill necessário, linhas antigas ficam em branco e continuam funcionando normalmente, já que só a regra da Amil/Tecnomyl lê esse campo). + - `matcher.py` grava esse campo **sempre** no momento do casamento (não só quando há regra de empresa ativa), a partir do `Individuo.tipo` do arquivo da operadora — `_casa_por_cpf`: `linha_destino.tipo_pessoa = ind.tipo`; `_casa_por_nome`: `linha_titular.tipo_pessoa = "T"` / `linha_dep.tipo_pessoa = m.tipo`. + - `views.py` (`ImportacaoPlanoSaudeAuditoriaViewSet.resolver`/`...DePaulaAuditoriaViewSet.resolver`) também grava `linha.tipo_pessoa = item.tipo` antes de recalcular a família ao "Vincular pessoa" manualmente — essa linha nunca passou pelo casamento automático, então sem isso cairia sempre no fallback "D" (tratando um agregado vinculado à mão como se fosse dependente). `tipo_pessoa` também entrou em `PLANO_SAUDE_CAMPOS_ALTERACAO` (snapshot de `ImportacaoPlanoSaudeAlteracao.dados_linha`), pra uma exclusão revertida recriar a linha com o `tipo_pessoa` correto. +3. **`_regra_amil_898_tecnomyl`** (`regras_empresa.py`, registrada como `amil_898_tecnomyl`): diferente da Tecnomyl-Unimed (teto por família), é uma regra **por pessoa**, sem interação entre membros da família — lê `linha.tipo_pessoa` (fallback "D" se em branco) e manda 100% pra `valor_empresa` (T/D) ou 100% pra `valor` (A). `chave_casamento="cpf"`, `tipos_lancamento=("mensalidade",)`. Validado com o pipeline completo (`processa_importacao` ponta a ponta, com uma planilha padrão sintética) além do teste isolado da função — nenhum erro na divisão titular/dependente vs. agregado. + +**Nota operacional pra próxima competência**: alguns dependentes do arquivo real vêm sem CPF (crianças, principalmente) — normalizado pra `"00000000000"`. Como a estratégia "cpf" (`_casa_por_cpf`) não aceita `vinculos_por_nome` (só a estratégia "nome" reaplica automaticamente um vínculo salvo), uma dessas pessoas vinculada manualmente via "Vincular pessoa" precisa ser vinculada de novo toda competência — comportamento pré-existente de qualquer operadora "cpf" (Amil, SulAmérica, MetLife), não introduzido por esta regra. Ainda não cadastrado em `RegraCusteioPlanoSaude` (banco de produção) — cadastro fica a cargo do usuário direto pela tela "Cadastro de Regras", não é feito por script/ORM neste ambiente. diff --git a/portal_api/planos_saude/CLAUDE.md b/portal_api/planos_saude/CLAUDE.md index 4932ff6..c97e224 100644 --- a/portal_api/planos_saude/CLAUDE.md +++ b/portal_api/planos_saude/CLAUDE.md @@ -20,7 +20,7 @@ portal_api/planos_saude/ ├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome) ├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome) ├── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo) - ├── amil/odonto_mensalidade.py Amil Odonto — 3758 e 898, dois cadastros da mesma operadora no Questor (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo) + ├── amil/odonto_mensalidade.py Amil Odonto — 3758 e 898, dois cadastros da mesma operadora no Questor (PDF via pdfplumber **ou** Excel via openpyxl — detectado pela extensão do arquivo, ver nota abaixo —, só mensalidade, casamento por CPF) ├── unimed_vitoria/saude.py Unimed Vitória — 4750 (2 PDFs sempre separados, mensalidade+coparticipação, casamento por nome, ver nota abaixo) ├── sulamerica/odonto_mensalidade.py SulAmérica Odonto — 4726 (PDF via pdfplumber, só mensalidade, casamento por CPF, ver nota abaixo) ├── sulamerica/saude.py SulAmérica Saúde — 5775 (Ottimizza; .xlsx via openpyxl, mensalidade+coparticipação, casamento por CPF, custeio decidido pela "Regra empresa" 1889 - SulAmérica, não pelo parser — ver nota abaixo) @@ -41,6 +41,10 @@ Pra adicionar uma operadora nova: criar `operadoras//.py` impleme **Amil Odonto tem um segundo cadastro no Questor, código 898** — pedido explícito do usuário: a mesma operadora/mesmo layout de arquivo está cadastrada duas vezes no Questor, com `codigo_operadora` diferentes (`CODIGOOUTEMP`), porque empresas distintas usam um ou outro cadastro. `pipeline.OPERADORAS` ganhou uma segunda chave, `amil_odonto_mensalidade_898` (`codigo_operadora="898"`, mesma `nome="Amil Odonto"`), reaproveitando a **mesma classe** `AmilOdontoMensalidade` — regras de extração e de custeio são idênticas às de `amil_odonto_mensalidade`/3758, só muda qual código filtra a planilha padrão buscada no Questor (`busca_linhas_questor`, ver "Planilha padrão via Questor (SQL)" abaixo) e qual aparece no combobox/label ("3758 - Amil Odonto" vs "898 - Amil Odonto"). Ao cadastrar uma regra de custeio (Cadastro de Regras) pra uma empresa que usa o cadastro 898, escolher explicitamente essa entrada no combobox de Operadora, não a de 3758. Se outra operadora aparecer duplicada no Questor do mesmo jeito, replicar este padrão: uma chave por código, mesma classe de `parser`. +**Amil Odonto (898) também lê Excel, não só PDF** — o cliente Tecnomyl (empresa 1778, cadastro 898) manda o mesmo relatório "Demonstrativo Analítico Faturamento" em `.xlsx`, não em PDF. `AmilOdontoMensalidade.extrai()` detecta a extensão do arquivo (`.xlsx`/`.xlsm` → `_extrai_xlsx`, via `openpyxl`; qualquer outra → o caminho PDF já existente, inalterado) — mesmo padrão de detecção por formato já usado pela Unimed Saúde (CSV/PDF). `_localiza_cabecalho_xlsx` acha a linha de cabeçalho de verdade (`Código`/`Beneficiário`/`CPF`/`Tipo`/`Mensalidade`) procurando pelo NOME das colunas dentro das primeiras 20 linhas, não por um número de linha fixo — o arquivo real tem um número variável de linhas de título/contrato/fatura acima da tabela. "Total Família" é só um subtotal por família (mesmo espírito do "Valor Total" do PDF, item 1 acima — ignorado); o valor de cada linha vem de "Mensalidade", somado por `Código` (um mesmo beneficiário pode se repetir em várias linhas — rubricas normais + devoluções/exclusões retroativas de competências anteriores cobradas na mesma fatura, mesma regra geral de "somar por indivíduo"). A coluna "Tipo" já vem como 'T'/'D'/'A' direto do relatório, sem precisar de nenhuma inferência a partir da "Dependência" (livre, só informativa). CPF normalizado + `.zfill(11)` por segurança (mesmo cuidado da MetLife). Validado rodando `extrai()` de ponta a ponta contra o arquivo real da competência 08/2026 (contrato Tecnomyl): 475 beneficiários (217 titulares/220 dependentes/38 agregados), cada um pagando um valor fixo por plano (R$ 20,81 no plano "DENTAL E200 R PJ_PROT", R$ 16,01 no "DENTAL 200 R PJ_DOC") — bate exatamente com a régua "Tipo" T/D/A do relatório real batendo 1:1 com os exemplos de "agregado" (avós, tios, sobrinhos, sogros, pai/mãe, irmãos) repassados pelo cliente pra `amil_898_tecnomyl` (ver "Regra empresa" abaixo). **2 beneficiários com valor final negativo** no arquivo real (exclusão retroativa de competência anterior cobrada nesta fatura) corretamente caem em auditoria (`VALOR_NEGATIVO`), sem relação com o parser em si — comportamento já existente do `matcher.py`, não precisou de nenhum tratamento novo. + +**Nota operacional (não é bug, mas vale saber)**: alguns dependentes do arquivo real vêm sem CPF (campo vazio) — normalizado para `"00000000000"`. Como `chave_casamento="cpf"` pra esta operadora, esse tipo de linha só casa automaticamente se a planilha padrão do Questor também tiver um CPF preenchido pra essa pessoa (o caso normal); se o Questor tiver o CPF real da pessoa (diferente de zeros), a linha cai em auditoria "CPF não encontrado" e precisa de "Vincular pessoa" manual. Diferente da estratégia "nome" (`_casa_por_nome`), a estratégia "cpf" (`_casa_por_cpf`) **não** aceita `vinculos_por_nome` — um vínculo salvo ao resolver manualmente esse caso não é reaplicado automaticamente numa competência futura (a mesma pessoa sem CPF precisaria ser vinculada de novo todo mês). Isso não é uma limitação nova desta rodada, é um comportamento pré-existente de qualquer operadora com `chave_casamento="cpf"` (Amil, SulAmérica, MetLife) — só ficou mais visível aqui porque o arquivo real da Tecnomyl tem vários dependentes menores de idade sem CPF cadastrado. Estender o DE/PARA por nome pra também cobrir a estratégia "cpf" seria uma mudança maior, fora do escopo desta rodada — avaliar se vale a pena caso isso vire um incômodo recorrente. + **Unimed Vitória (4750)** — sempre 2 PDFs separados (nunca detecta "tipo de documento" escolhido pelo usuário, detecção automática pelo conteúdo, mesmo espírito da Unimed do Paraná): "Demonstrativo Analítico de Pré Pagamento" (mensalidade) e "Extrato de Co-Participação" (coparticipação), nenhum dos dois com CPF (casamento por nome). Validado contra os dois arquivos reais do cliente Weitnauer Brasil (`pdfplumber` rodou de fato, batendo com os valores impressos no próprio relatório — R$ 340,74 de mensalidade, R$ 55,57 de coparticipação — e casando certo contra a planilha padrão real da empresa 792). Particularidade de extração: a coluna de nome do relatório de mensalidade quebra em duas linhas físicas quando o nome é longo, misturada com a linha de dados num `top` próximo mas não igual — nem `extract_text()` nem `extract_text(layout=True)` resolvem isso sem ambiguidade, então o parser reconstrói as linhas a partir de `extract_words(extra_attrs=["fontname","size"])` agrupadas por posição vertical, e usa sempre o cabeçalho em negrito (nome completo, sem quebra) como fonte do nome, nunca a linha de dados quebrada; a coparticipação não tem espaço literal nenhum entre colunas (todo espaçamento é por posição, não por caractere), o que também exige `extract_words()` em vez de concatenar `page.chars` direto. **Titular/dependente é uma suposição não validada**: nenhum dos dois relatórios traz um marcador textual "Titular"/"Dependente" explícito, e os dois arquivos de exemplo só têm titular, sem nenhum dependente — a classificação usada (sequência "00" da carteirinha "..-" = titular, qualquer outra = dependente) é a convenção nacional já conhecida de outras Unimeds, mas nunca confirmada contra um arquivo real desta operadora com dependente; testar com um caso real antes de confiar nela — a validação prévia ("Selecionar arquivo") não pega esse tipo de erro, já que a extração não falha, só classificaria errado. **SulAmérica Odonto (4726)** — um único PDF, sem coparticipação (relatório "Conferência de Faturamento PJ (Completo)" do sistema "IS Odonto" da própria operadora — é só uma cobrança fixa periódica por beneficiário, não há linha de serviço/atendimento nenhuma). Ao contrário das Unimeds, este relatório traz **CPF de todo mundo** (casamento por CPF, mais seguro) e um campo textual explícito de "Grau parentesco" (TITULAR/CONJUGE/OUTROS/DEP PERMANENTE/...) — nenhuma suposição sobre numeração de carteirinha foi necessária aqui. Validado rodando `pdfplumber` e o `pipeline.processa_importacao` completo contra o arquivo real (15 beneficiários, R$ 437,40 no total — bate exatamente com "Total R$ 437,40" impresso no relatório) e a planilha padrão real da empresa 792 (13 dos 15 beneficiários casaram certo por CPF; os 2 ausentes da planilha de teste foram corretamente para auditoria "CPF não encontrado", não ignorados). **Cada família tem um "totalizador" impresso ao final** (ex.: "R$ 87,48" somando os 3 beneficiários de uma família) — por pedido explícito do usuário, esse total **nunca é usado**: o lançamento é sempre feito pela coluna "Valor" de cada linha de beneficiário individual (R$ 29,16 no exemplo), a mesma lógica de "usar o valor por linha, ignorar o subtotal impresso" já aplicada à Unimed do Paraná/Vitória. Particularidade de extração: quando o nome de um beneficiário (ou da família, no cabeçalho) ultrapassa a largura da coluna, o próprio relatório **corta o texto sem reticências e sem terminar de completar a última palavra** (ex.: "DANIELE MARTINS FERREIRA DA SILVA" sai como "DANIELE MARTINS FERREIRA DA" + "SILV" cortado, perdendo o "A" final) — como o casamento é por CPF, isso nunca afeta a correção do lançamento (nome é só exibição), então o parser não tenta reconstruir o nome quebrado, só usa a primeira linha física de cada beneficiário (onde já estão código/CPF/data nascimento/grau/valor completos, nenhum desses quebra, só o nome às vezes). @@ -86,6 +90,7 @@ Essa divisão é calculada por `matcher._calcula_valores(valor_total, regra)` (c - `ImportacaoPlanoSaudeAuditoria`: espelha `ItemAuditoria` — os campos extraídos do arquivo da operadora (`motivo`/`nome`/`valor`/`detalhe`...) são read-only na tela; `resolvida`/`linha_vinculada` são a exceção, graváveis via a resolução manual (ver "Resolução manual de auditoria por nome" abaixo). `MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")` (atributo de classe) é a lista dos dois motivos "de leitura/grafia de nome" que aceitam esse fluxo — `VALOR_NEGATIVO`/`TIPO_INVALIDO` são outra categoria de problema (valor real negativo, tipo de despesa não mapeado) e não têm solução por "essa é a mesma pessoa". - `ImportacaoPlanoSaudeAlteracao`: log de cada edição de campo/inclusão/exclusão de linha feita manualmente na revisão — ver seção "Alterações" abaixo. - `RegraCusteioPlanoSaude`: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada, mesmo espírito de `LinkFerramenta`/`AcessoGeral` — o próprio model não tem FK pra nada; é `ImportacaoPlanoSaude.regra_custeio_salva` que aponta pra cá (opcional, `SET_NULL`), só como registro de qual regra (se alguma) foi aplicada pra preencher aquele formulário. +- `ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeDePaulaLinha` ganharam `tipo_pessoa` (`CharField(max_length=1, blank=True)`, migração `0074`) — 'T'/'D'/'A' (Titular/Dependente/Agregado), espelhando `LinhaSistema.tipo_pessoa` (`planos_saude/modelos.py`). Não é exposto em nenhum serializer (uso só interno, nenhum consumidor de frontend precisa dele) — existe só pra alimentar uma regra de empresa que precise diferenciar dependente de agregado por PESSOA (a planilha padrão do sistema em si só distingue titular/não-titular, via `nome_dependente`/`cpf_dependente` vazios ou não — nunca soube separar "D" de "A"). Ver "Regra empresa" abaixo (`amil_898_tecnomyl`) pro primeiro consumidor real. ## Fluxo e endpoints @@ -217,7 +222,7 @@ O bloco de checkboxes/radios de custeio (`.ips-tipo-field`, mensalidade/copartic ## Regra empresa (custeio especial por empresa, mensalidade e/ou coparticipação) -Cobre regras de custeio negociadas com uma empresa específica que não cabem no desenho normal "por tipo de lançamento × titular/dependente" — por serem calculadas por **família inteira** (titular + dependentes somados, não por pessoa) e/ou por serem um critério fixo (não um percentual/teto configurável). O algoritmo em si (`portal_api/planos_saude/regras_empresa.py`, `REGRAS_EMPRESA: Dict[str, dict]`) continua sendo um registro fixo no código, cadastrado pelo desenvolvedor quando o cliente repassa uma regra nova — a escolha de USAR uma regra especial vive dentro do Cadastro de Regras por empresa+operadora: o campo `RegraCusteioPlanoSaude.regra_empresa_chave` (chave de `REGRAS_EMPRESA`) é configurado uma vez, junto do resto do custeio, no modal "Cadastro de Regras" — "Nova Importação" só resolve o que já foi cadastrado, sem checkbox próprio. +Cobre regras de custeio negociadas com uma empresa específica que não cabem no desenho normal "por tipo de lançamento × titular/dependente" — por serem calculadas por **família inteira** (titular + dependentes somados, não por pessoa), por serem um critério fixo (não um percentual/teto configurável) e/ou por dependerem de uma distinção por PESSOA que o sistema não guarda (ex.: dependente vs. agregado — ver `amil_898_tecnomyl`/`tipo_pessoa` abaixo). O algoritmo em si (`portal_api/planos_saude/regras_empresa.py`, `REGRAS_EMPRESA: Dict[str, dict]`) continua sendo um registro fixo no código, cadastrado pelo desenvolvedor quando o cliente repassa uma regra nova — a escolha de USAR uma regra especial vive dentro do Cadastro de Regras por empresa+operadora: o campo `RegraCusteioPlanoSaude.regra_empresa_chave` (chave de `REGRAS_EMPRESA`) é configurado uma vez, junto do resto do custeio, no modal "Cadastro de Regras" — "Nova Importação" só resolve o que já foi cadastrado, sem checkbox próprio. **Deixou de ser exclusivo de "mensalidade"** — cada regra em `REGRAS_EMPRESA` agora declara `tipos_lancamento` (quais tipos ela cobre — a Tecnomyl abaixo só cobre `("mensalidade",)`, a Ottimizza abaixo cobre `("mensalidade", "coparticipacao")`) e `chave_casamento` (que estratégia de casamento a regra exige — "nome" pra regras que precisam agrupar família, "cpf" pra regras por pessoa sem agrupamento). Por isso o checkbox do Cadastro de Regras foi renomeado de "Mensalidade usa regra especial da empresa" pra **"Regra especial da empresa"** (`#ips-form-tipo-regra-empresa`, mesmo id) — decisão explícita do usuário, "considerando que neste lugar trata não apenas mensalidade mas também a coparticipação". @@ -230,5 +235,10 @@ Cobre regras de custeio negociadas com uma empresa específica que não cabem no - **Cálculo é por família, com prioridade explícita: dependentes primeiro, titular absorve o residual** (`regras_empresa._aplica_teto_familia`) — decisão explícita do cliente, e diferente de uma primeira versão (revertida) que distribuía o teto **proporcionalmente** entre todas as linhas. O algoritmo percorre primeiro os dependentes (na ordem em que aparecem no arquivo da operadora), cada um recebendo `valor_empresa = min(seu valor, o que sobrou do teto)`; só depois de todos os dependentes processados o titular absorve o que sobrou do teto (`teto_restante`), com o excedente (se houver) virando desconto do empregado nessa mesma linha. Ex.: família com dependente de R$559,57 e titular de R$314,12 (teto R$661,61) — dependente sai com `valor_empresa=559,57`/`valor=0` (coberto integralmente), sobra `661,61-559,57=102,04` de teto pro titular, que sai com `valor_empresa=102,04`/`valor=212,08`. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica com `valor_empresa=0` (desconto integral) e, se ainda sobrar dependente sem cobrir depois disso, esse dependente também é parcialmente descontado. Validado rodando o pipeline direto com os dois exemplos passados pelo cliente (família de R$800 → R$661,61 empresa/R$138,39 empregado no total; família abaixo do teto → 100% empresa) e reproduzindo exatamente um caso real reportado pelo usuário (família Caroline Fernandes/Luciano Ramos, R$873,69 no total) depois do ajuste de prioridade. - **`_aplica_teto_familia` é duck-typed de propósito** (`linhas_e_valores: List[Tuple[Any, float]]`, `_eh_linha_titular()` própria em vez de `LinhaSistema.eh_linha_titular()`): roda tanto contra `LinhaSistema` (pipeline, na criação da importação) quanto contra `ImportacaoPlanoSaudeLinha` (model Django, no recálculo pós "Vincular pessoa" — ver `views._recalcula_familia_regra_empresa` e "Resolução manual de auditoria por nome" acima) — as duas classes têm os mesmos atributos de string (`nome_dependente`/`cpf_dependente`/`valor_empresa`/`valor`), só a segunda não tem o método `eh_linha_titular()`. - **`sulamerica_5775_ottimizza`** — Ottimizza (código 1889) na SulAmérica (5775, ver "SulAmérica Saúde" acima): critério fixo, sem teto/percentual — mensalidade do titular é 100% custeada pela empresa, mensalidade do dependente é 100% descontada do empregado, e toda coparticipação (titular ou dependente) é 100% descontada do empregado. `chave_casamento="cpf"` (o parser já resolve cada indivíduo por CPF, sem precisar agrupar família — `_regra_sulamerica_5775_ottimizza` decide por linha, olhando só `_eh_linha_titular(linha)` e o `tipo_lancamento` recebido), `tipos_lancamento=("mensalidade", "coparticipacao")` — as duas cobertas pela mesma função, que ramifica por `tipo_lancamento`. Reproduz exatamente o padrão observado na planilha real da Ottimizza (toda linha de titular só vem com "Benefício Mensalidade" preenchido, toda linha de dependente só com "Desconto Mensalidade", "Benefício Coparticipação" nunca preenchido) — confirmado rodando `pipeline.processa_importacao` de ponta a ponta com a regra ativa contra o arquivo real e batendo centavo a centavo com as 4 colunas somadas direto da planilha (R$ 23.722,14 empresa/R$ 1.404,26 empregado de mensalidade; R$ 1.540,84 empregado de coparticipação). +- **`amil_898_tecnomyl`** — Amil Odonto (código 898) na Tecnomyl (empresa 1778), repassada pelo cliente em 09/2026: a empresa custeia 100% da mensalidade de titular e dependente direto (cônjuge, filho(a)); agregados (avós, tios, sobrinhos, sogros, pai/mãe, irmãos) têm a mensalidade 100% descontada do empregado, no MESMO valor por pessoa — a regra é só sobre QUEM paga, não sobre um teto/percentual, por isso cobre os dois planos do contrato (E200 a R$ 20,81/pessoa e "DENTAL 200" a R$ 16,01/pessoa) sem precisar de nenhum valor fixo no código. `chave_casamento="cpf"` (o parser já resolve cada indivíduo por CPF), `tipos_lancamento=("mensalidade",)` — Amil Odonto não traz coparticipação (ver docstring do parser). + - **Diferente das outras duas regras: não usa família nem `_eh_linha_titular`** — é a primeira regra de empresa que precisa diferenciar dependente de agregado por PESSOA, uma distinção que a planilha padrão do sistema não guarda (só sabe titular/não-titular). Por isso `LinhaSistema`/`ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeDePaulaLinha` ganharam `tipo_pessoa` ('T'/'D'/'A', ver "Modelos" acima) — gravado **sempre** (não só quando há uma regra de empresa ativa) pelo `matcher.py` no momento do casamento, a partir do `Individuo.tipo` original do arquivo da operadora (`_casa_por_cpf`: `linha_destino.tipo_pessoa = ind.tipo`; `_casa_por_nome`: `linha_titular.tipo_pessoa = "T"` / `linha_dep.tipo_pessoa = m.tipo`). `_regra_amil_898_tecnomyl` só lê `getattr(linha, "tipo_pessoa", "") or "D"` de cada linha (fallback pra "D" — custeada pela empresa — quando em branco, ex.: linha incluída manualmente via "Adicionar linha", que nunca passa pelo casamento automático). + - **"Vincular pessoa" (resolução manual de auditoria) também grava `tipo_pessoa`**: como a linha resolvida nunca passou pelo casamento automático (senão não estaria em branco), `ImportacaoPlanoSaudeAuditoriaViewSet.resolver`/`...DePaulaAuditoriaViewSet.resolver` gravam `linha.tipo_pessoa = item.tipo` antes de chamar `_recalcula_familia_regra_empresa(_de_paula)` — sem isso, uma linha vinculada manualmente cairia sempre no fallback "D", tratando um agregado vinculado à mão como se fosse dependente. + - **Nota operacional**: dependentes sem CPF no arquivo real da Tecnomyl (comum em crianças) caem em auditoria "CPF não encontrado" se a planilha padrão do Questor tiver o CPF real dessa pessoa — e, ao contrário da estratégia "nome", a estratégia "cpf" não reaplica um vínculo salvo automaticamente numa competência futura (`_casa_por_cpf` não aceita `vinculos_por_nome`), então a mesma pessoa precisa ser vinculada de novo todo mês. Comportamento pré-existente de qualquer operadora "cpf" (Amil, SulAmérica, MetLife), não uma limitação introduzida por esta regra — ver nota em "Formato Excel (898, Tecnomyl)" acima. + - Validado rodando `extrai()` + a regra + o pipeline completo (`processa_importacao`) contra o arquivo real da competência 08/2026 — ver detalhe em "Formato Excel (898, Tecnomyl)" acima. - **Trava de compatibilidade generalizada**: `regras_empresa.valida_regra_empresa()` recusa explicitamente (`RegraEmpresaIncompativelError`, capturada à parte em `views.py` pra devolver a mensagem certa, não o erro genérico de "formato de arquivo") se (a) nenhum tipo selecionado na importação está entre os `tipos_lancamento` da regra, (b) a operadora escolhida não usa a `chave_casamento` que a regra exige pra algum tipo coberto, ou (c) a planilha padrão anexada não tem nenhuma linha com o `codigo_empresa` esperado pela regra — trava contra aplicar a regra de uma empresa a outra por engano (vale pras duas regras, não só pra Tecnomyl como antes). - **"Vincular pessoa" (resolução manual de auditoria) também generalizada**: `_recalcula_familia_regra_empresa` (views.py) filtra por `linha.tipo_lancamento` (o tipo da própria linha resolvida), não mais fixo em `"mensalidade"`, e passa esse tipo como segundo argumento pra `regra["aplica"]`; `resolver()` decide se aplica esse caminho checando se `item.tipo_lancamento` está em `REGRAS_EMPRESA[chave]["tipos_lancamento"]`, não mais comparando com a string `"mensalidade"` direto. diff --git a/portal_api/planos_saude/matcher.py b/portal_api/planos_saude/matcher.py index 478dcee..7ca95e1 100644 --- a/portal_api/planos_saude/matcher.py +++ b/portal_api/planos_saude/matcher.py @@ -240,6 +240,12 @@ def _casa_por_cpf( auditoria.append(_item_nao_cadastrado(ind, "CPF não encontrado na planilha padrão do sistema.")) continue + # Sempre gravado (não só quando há regra_empresa) — é a única forma + # de uma regra de empresa futura saber, por uma LinhaSistema já + # persistida, se ela era um dependente direto ou um agregado no + # arquivo original da operadora (ver `LinhaSistema.tipo_pessoa`). + linha_destino.tipo_pessoa = ind.tipo + if regra_empresa_fn is not None: linhas_e_valores.append((linha_destino, ind.valor_total)) else: @@ -349,6 +355,7 @@ def _casa_por_nome( # a partir da planilha, então a chave certa é o nome REAL do # titular na planilha (`linha_titular.nome_func`), não o nome que # veio do arquivo da operadora (que pode ser divergente). + linha_titular.tipo_pessoa = "T" # sempre gravado, ver nota em _casa_por_cpf dependentes_da_familia = dependentes_por_titular.get(normaliza_nome(linha_titular.nome_func), {}) linhas_e_valores_familia: List[Tuple[LinhaSistema, float]] = [] @@ -394,6 +401,8 @@ def _casa_por_nome( )) continue + linha_dep.tipo_pessoa = m.tipo # 'D' ou 'A', ver nota em _casa_por_cpf + if regra_empresa_fn is not None: linhas_e_valores_familia.append((linha_dep, m.valor_total)) else: diff --git a/portal_api/planos_saude/modelos.py b/portal_api/planos_saude/modelos.py index 04d30aa..5edea3e 100644 --- a/portal_api/planos_saude/modelos.py +++ b/portal_api/planos_saude/modelos.py @@ -84,6 +84,16 @@ class LinhaSistema: valor_empresa: str valor: str descricao: str + # 'T'/'D'/'A' (Titular/Dependente/Agregado) — gravado pelo matcher no + # momento do casamento, a partir do Individuo.tipo do arquivo da + # operadora (ver matcher._casa_por_cpf/_casa_por_nome). A planilha + # padrão do sistema em si não distingue dependente de agregado (só + # titular/não-titular, via nome_dependente/cpf_dependente) — este campo + # existe só pra alimentar regras de empresa que precisam dessa + # distinção por pessoa (ex.: portal_api.planos_saude.regras_empresa, + # regra da Amil/Tecnomyl). Fica "" quando a linha nunca foi casada + # automaticamente (ex.: incluída manualmente via "Adicionar linha"). + tipo_pessoa: str = "" def cpf_func_normalizado(self) -> str: return normaliza_cpf(self.cpf_func) diff --git a/portal_api/planos_saude/operadoras/amil/odonto_mensalidade.py b/portal_api/planos_saude/operadoras/amil/odonto_mensalidade.py index 1be4b6a..9476e4b 100644 --- a/portal_api/planos_saude/operadoras/amil/odonto_mensalidade.py +++ b/portal_api/planos_saude/operadoras/amil/odonto_mensalidade.py @@ -3,10 +3,36 @@ Amil Odonto - Mensalidade. Porta de projects/project/operadoras/amil/odonto_mensalidade.py. Formato recebido: PDF "Demonstrativo Analítico de Faturamento - Por -Contrato / Empresa". Esta operadora normalmente não traz coparticipação, -só mensalidade — mas a arquitetura já está pronta para o dia em que -precisarmos ler coparticipação de outra operadora (basta criar uma classe -irmã, ex: operadoras/amil/odonto_coparticipacao.py). +Contrato / Empresa" (código 3758) **ou** Excel do mesmo relatório (código +898 — ver "Formato Excel (898, Tecnomyl)" abaixo), detectado automaticamente +pela extensão do arquivo (mesmo padrão de detecção por conteúdo/extensão já +usado por outras operadoras, ex.: Unimed Saúde CSV/PDF). Esta operadora +normalmente não traz coparticipação, só mensalidade — mas a arquitetura já +está pronta para o dia em que precisarmos ler coparticipação de outra +operadora (basta criar uma classe irmã, ex: operadoras/amil/odonto_coparticipacao.py). + +Formato Excel (898, Tecnomyl) — validado contra o arquivo real da +competência 08/2026 (509 linhas, contrato Tecnomyl): mesmo relatório +"Demonstrativo Analítico Faturamento", só que exportado em `.xlsx` em vez +de PDF. O cabeçalho de verdade (`Código`/`Beneficiário`/`Matrícula`/`CPF`/ +`Plano`/`Tipo`/`Idade`/`Dependência`/.../`Mensalidade`/`Total Família`) não +fica sempre na mesma linha (acima dele há linhas de título/contrato/fatura +cuja quantidade pode variar) — por isso `_localiza_cabecalho_xlsx` procura a +linha certa pelo NOME das colunas em vez de um número de linha fixo. +"Total Família" é só um subtotal por família (mesmo espírito do "Valor +Total" do PDF — ignorado, ver item 1 abaixo); o valor de cada linha vem da +coluna "Mensalidade". Um mesmo `Código` pode se repetir em várias linhas +(rubricas normais + devoluções/exclusões retroativas de competências +anteriores, cobradas na mesma fatura) — somadas por indivíduo, mesma regra +geral do pacote. "Tipo" já vem como 'T'/'D'/'A' direto do relatório +(confirmado: 'A' cobre exatamente avós/tios/sobrinhos/sogros/pai-mãe/irmãos +— os mesmos exemplos de "agregado" repassados pelo cliente para a regra de +custeio, ver `portal_api.planos_saude.regras_empresa`), então não precisa +de nenhuma inferência a partir da coluna "Dependência" (livre, só +informativa). CPF vem com máscara (pontos/traço) e como texto no arquivo +real — normalizado e preenchido com zeros à esquerda por segurança (mesmo +cuidado já necessário no relatório da MetLife, ver `operadoras/metlife/ +odonto_mensalidade.py`). Particularidades deste relatório (descobertas inspecionando o PDF real): @@ -42,11 +68,12 @@ agregados), R$ 1.630,93 no total — bate exatamente com os totais impressos no próprio relatório. """ import re -from typing import List, Tuple +from typing import Dict, List, Tuple +import openpyxl import pdfplumber -from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento +from portal_api.planos_saude.modelos import Individuo, ItemAuditoria, Lancamento, normaliza_cpf, normaliza_nome from portal_api.planos_saude.operadoras.base import OperadoraParser _ROW_RE = re.compile(r'^\s*(?P\d{9})\s+(?P.+)$') @@ -69,10 +96,70 @@ def _valor_para_float(texto: str) -> float: return -valor if negativo else valor +# Nomes de coluna esperados no cabeçalho do Excel (898), já normalizados +# (maiúsculo, sem acento) via `normaliza_nome` — usado só pra ACHAR a linha +# de cabeçalho dentro do arquivo, não como chave de casamento. +_COLUNAS_XLSX_ESPERADAS = ("CODIGO", "BENEFICIARIO", "CPF", "TIPO", "MENSALIDADE") + + class AmilOdontoMensalidade(OperadoraParser): nome_operadora = "AMIL" chave_casamento = "cpf" # Amil manda CPF de todo mundo + def _localiza_cabecalho_xlsx(self, ws: "openpyxl.worksheet.worksheet.Worksheet") -> Tuple[int, Dict[str, int]]: + """Acha a linha de cabeçalho de verdade dentro das primeiras linhas + do relatório (acima dela há linhas de título/contrato/fatura cuja + quantidade varia, então não é seguro fixar um número de linha) — + procura pelo NOME das colunas esperadas (`_COLUNAS_XLSX_ESPERADAS`), + não pela posição. Devolve `(número da linha, {coluna: índice})`.""" + for numero_linha, linha in enumerate(ws.iter_rows(min_row=1, max_row=20, values_only=True), start=1): + indices: Dict[str, int] = {} + for indice, valor in enumerate(linha): + if valor is None: + continue + indices[normaliza_nome(str(valor))] = indice + if all(coluna in indices for coluna in _COLUNAS_XLSX_ESPERADAS): + return numero_linha, indices + raise ValueError( + "Layout do Excel da Amil Odonto não reconhecido — cabeçalho " + "esperado (Código/Beneficiário/CPF/Tipo/Mensalidade) não " + "encontrado nas primeiras linhas do arquivo." + ) + + def _extrai_xlsx(self, caminho_xlsx: str) -> List[Lancamento]: + wb = openpyxl.load_workbook(caminho_xlsx, data_only=True, read_only=True) + ws = wb.worksheets[0] + linha_cabecalho, col = self._localiza_cabecalho_xlsx(ws) + + lancamentos: List[Lancamento] = [] + for linha in ws.iter_rows(min_row=linha_cabecalho + 1, values_only=True): + codigo = linha[col["CODIGO"]] if col["CODIGO"] < len(linha) else None + if codigo is None: + continue # linha em branco/rodapé, sem código de beneficiário + + tipo = str(linha[col["TIPO"]] or "").strip() if col["TIPO"] < len(linha) else "" + if tipo not in ("T", "D", "A"): + continue # linha de rodapé/total (ex.: sem "Tipo" válido) + + nome = str(linha[col["BENEFICIARIO"]] or "").strip() if col["BENEFICIARIO"] < len(linha) else "" + cpf_bruto = str(linha[col["CPF"]] or "") if col["CPF"] < len(linha) else "" + valor_bruto = linha[col["MENSALIDADE"]] if col["MENSALIDADE"] < len(linha) else 0 + + lancamentos.append(Lancamento( + numero_beneficiario=str(codigo), + nome=nome, + # CPF vem com máscara e como texto no arquivo real, mas + # `.zfill(11)` protege contra um export numérico futuro que + # perca o(s) zero(s) à esquerda (mesmo cuidado da MetLife, + # ver operadoras/metlife/odonto_mensalidade.py). + cpf=normaliza_cpf(cpf_bruto).zfill(11), + tipo=tipo, + rubrica="Mensalidade", + valor=float(valor_bruto or 0), + tipo_lancamento="mensalidade", + )) + return lancamentos + def _pdf_para_linhas(self, caminho_pdf: str) -> List[str]: """ Extrai o texto preservando o layout de colunas (equivalente ao @@ -136,7 +223,10 @@ class AmilOdontoMensalidade(OperadoraParser): return [individuos[c] for c in ordem] def extrai(self, caminho_arquivo: str) -> Tuple[List[Individuo], List[ItemAuditoria]]: - linhas = self._pdf_para_linhas(caminho_arquivo) - lancamentos = self._parseia_lancamentos(linhas) + if caminho_arquivo.lower().endswith((".xlsx", ".xlsm")): + lancamentos = self._extrai_xlsx(caminho_arquivo) + else: + linhas = self._pdf_para_linhas(caminho_arquivo) + lancamentos = self._parseia_lancamentos(linhas) individuos = self._agrega_por_individuo(lancamentos) return individuos, [] # Amil não gera itens de auditoria na extração diff --git a/portal_api/planos_saude/regras_empresa.py b/portal_api/planos_saude/regras_empresa.py index 2f8599c..73acfcd 100644 --- a/portal_api/planos_saude/regras_empresa.py +++ b/portal_api/planos_saude/regras_empresa.py @@ -85,6 +85,38 @@ def _regra_unimed_1778_tecnomyl(linhas_e_valores: List[Tuple[Any, float]], tipo_ _aplica_teto_familia(linhas_e_valores, teto=661.61) +def _regra_amil_898_tecnomyl(linhas_e_valores: List[Tuple[Any, float]], tipo_lancamento: str) -> None: + """Amil Odonto (código 898) na Tecnomyl, repassada pelo cliente em + 09/2026: a empresa custeia 100% da mensalidade de titular e dependente + direto (cônjuge, filho(a)); agregados — avós, tios, sobrinhos, sogros e + similares — têm a mensalidade 100% descontada do empregado, no MESMO + valor (a regra é só sobre quem paga, não sobre um teto/percentual, por + isso cobre os dois planos do contrato — E200 a R$ 20,81/pessoa e + "DENTAL 200" a R$ 16,01/pessoa — sem precisar de nenhum valor fixo + aqui). Confirmado contra o arquivo real da competência 08/2026: todo + titular/dependente/agregado paga exatamente o mesmo valor por pessoa + dentro do mesmo plano, e a discriminação titular/dependente/agregado do + relatório (coluna "Tipo": T/D/A) bate exatamente com os exemplos do + cliente (avós, sogros, sobrinhos = agregado). + + Ao contrário da Tecnomyl-Unimed (`_aplica_teto_familia`, um teto por + família), esta regra é por PESSOA, sem nenhuma interação entre membros + da família — usa `linha.tipo_pessoa` ('T'/'D'/'A', gravado pelo + matcher no momento do casamento a partir do Individuo.tipo original do + arquivo da operadora — ver `LinhaSistema.tipo_pessoa` — já que a + planilha padrão do sistema não distingue dependente de agregado por si + só). Uma linha sem `tipo_pessoa` preenchido (nunca passou pelo + casamento automático — ex.: incluída manualmente via "Adicionar linha") + cai no fallback "D": custeada pela empresa, o mesmo comportamento que + já valeria pra ela antes desta regra existir.""" + for linha, valor in linhas_e_valores: + valor = max(0.0, valor) + tipo_pessoa = getattr(linha, "tipo_pessoa", "") or "D" + eh_agregado = tipo_pessoa == "A" + linha.valor_empresa = formata_valor_br(0.0 if eh_agregado else valor) + linha.valor = formata_valor_br(valor if eh_agregado else 0.0) + + def _regra_sulamerica_5775_ottimizza(linhas_e_valores: List[Tuple[Any, float]], tipo_lancamento: str) -> None: """Ottimizza (código 1889) na SulAmérica (5775) — critério fixo, sem depender de teto/percentual: a mensalidade do titular é 100% custeada @@ -141,6 +173,22 @@ REGRAS_EMPRESA: Dict[str, dict] = { "configurado nesta importação, sem relação com esta regra." ), }, + "amil_898_tecnomyl": { + "label": "1778 - Amil (Tecnomyl)", + "codigo_empresa": "1778", + "operadora": "amil_odonto_mensalidade_898", + "chave_casamento": "cpf", + "tipos_lancamento": ("mensalidade",), + "aplica": _regra_amil_898_tecnomyl, + "observacoes": ( + "A Tecnomyl arca com o valor integral da mensalidade de titular e " + "dependente direto (cônjuge, filho(a)). Agregados — avós, tios, " + "sobrinhos, sogros e similares — têm a mensalidade 100% " + "descontada do empregado, no mesmo valor por pessoa. Cobre os " + "planos do contrato (E200 e DENTAL 200), cada um no seu próprio " + "valor de mensalidade." + ), + }, "sulamerica_5775_ottimizza": { "label": "1889 - SulAmérica (5775)", "codigo_empresa": "1889", diff --git a/portal_api/views.py b/portal_api/views.py index 570bb86..0e4d759 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -878,6 +878,7 @@ def _nome_base_arquivo_gerado_plano_saude(importacao: Any, todas_linhas: Iterabl PLANO_SAUDE_CAMPOS_ALTERACAO = [ "codigo_empresa", "nome_func", "cpf_func", "codigo_out_emp", "data_inicial", "nome_dependente", "cpf_dependente", "valor_empresa", "valor", "descricao", + "tipo_pessoa", ] @@ -1163,6 +1164,7 @@ class ImportacaoPlanoSaudeViewSet(viewsets.ModelViewSet): valor_empresa=linha.valor_empresa, valor=linha.valor, descricao=linha.descricao, + tipo_pessoa=linha.tipo_pessoa, ) for tipo, ordem, linha in linhas_specs ]) @@ -1512,10 +1514,15 @@ class ImportacaoPlanoSaudeAuditoriaViewSet(viewsets.GenericViewSet): # 100% desconto do empregado). Grava o valor bruto aqui primeiro # (valor_empresa="0" é só um placeholder) e reaplica a regra em # toda a família, que recupera o valor bruto de cada linha como - # valor_empresa + valor. + # valor_empresa + valor. `tipo_pessoa` também precisa ser + # gravado aqui — esta linha nunca passou pelo casamento + # automático (senão não estaria em branco pra "Vincular + # pessoa"), então uma regra que distinga dependente de agregado + # (ex.: Amil/Tecnomyl) só sabe o tipo de `item.tipo`. linha.valor_empresa = "0" linha.valor = formata_valor_br(float(item.valor)) - linha.save(update_fields=["valor_empresa", "valor"]) + linha.tipo_pessoa = item.tipo + linha.save(update_fields=["valor_empresa", "valor", "tipo_pessoa"]) _recalcula_familia_regra_empresa(item.importacao, linha) else: regra_por_pessoa = (item.importacao.custeio_por_tipo or {}).get(item.tipo_lancamento, {}) @@ -1794,6 +1801,7 @@ class ImportacaoPlanoSaudeDePaulaViewSet(viewsets.ModelViewSet): valor_empresa=linha.valor_empresa, valor=linha.valor, descricao=linha.descricao, + tipo_pessoa=linha.tipo_pessoa, ) for tipo, ordem, linha in linhas_specs ]) @@ -2061,9 +2069,12 @@ class ImportacaoPlanoSaudeDePaulaAuditoriaViewSet(viewsets.GenericViewSet): regra_empresa_dados and item.tipo_lancamento in regra_empresa_dados.get("tipos_lancamento", ("mensalidade",)) ) if regra_empresa_cobre_este_tipo: + # Ver ImportacaoPlanoSaudeAuditoriaViewSet.resolver — mesmo + # motivo pra gravar `tipo_pessoa` aqui. linha.valor_empresa = "0" linha.valor = formata_valor_br(float(item.valor)) - linha.save(update_fields=["valor_empresa", "valor"]) + linha.tipo_pessoa = item.tipo + linha.save(update_fields=["valor_empresa", "valor", "tipo_pessoa"]) _recalcula_familia_regra_empresa_de_paula(item.importacao, linha) else: regra_por_pessoa = (item.importacao.custeio_por_tipo or {}).get(item.tipo_lancamento, {})