diff --git a/CLAUDE.md b/CLAUDE.md
index c42d9c4..dbc96fc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,22 +52,16 @@ Configurar as variáveis de ambiente do Postgres 14 antes de migrar — `config/
```
python manage.py makemigrations portal_api
python manage.py migrate
-python manage.py seed_portal # recria os 8 perfis + usuários gabriel/bruno
python manage.py runserver
```
-Abrir `http://localhost:8000/` — o Django serve `index.html` e as demais páginas do frontend diretamente (ver `config/urls.py`), então **não há mais um segundo servidor** (`python -m http.server`) para o frontend.Vamos desenvolver outra
-
-Contas de demonstração (criadas por `seed_portal`, senha via `set_password` do Django — não é mais texto puro):
-
-- `gabriel` / `gabriel` — perfil "Integração e Inovação" (código 8), o único com `gerencia_permissoes=True` (acesso total + gerencia Perfis de Acesso/Usuários) e o único com os `*-editar` de `links-ferramentas` (cartões e Acessos Gerais) e de `ramais` `True` (único perfil que pode reordenar/incluir/remover cartões em Links & Ferramentas, criar/editar seções e acessos em Acessos Gerais, e adicionar/editar ramal/criar ausência em Ramais, até que outro perfil seja liberado em Perfis de Acesso — ver "Modelo de permissões" abaixo).
-- `bruno` / `bruno` — sem perfil vinculado, usado para testar o estado "sem acesso".
+**`python manage.py seed_portal` é só para ambiente novo/vazio (primeira criação do banco)** — não rodar mais neste ambiente. **O Portal já está em produção**: os 8 perfis de acesso e todas as contas de usuário (incluindo `gabriel`/`bruno`) já existem de verdade, com senhas e permissões mantidas pelos próprios usuários direto pela tela (Perfis de Acesso/Usuários) — não mais pelo seed. `seed_portal` resincroniza incondicionalmente `permissoes`/`ativo`/`gerencia_permissoes` dos 8 perfis de código fixo a partir de `catalogo.py` a cada execução (ver "Cuidado com `seed_portal.py`" mais abaixo) — rodar isso contra o banco já em uso reverteria qualquer permissão que um admin tenha customizado manualmente para um desses 8 perfis, sem nenhum aviso. Ao adicionar uma aplicação nova ao catálogo (novo `app_key`), a chave nova ainda entra automaticamente pros perfis certos na próxima vez que `seed_portal` rodar — mas evitar rodar só por causa disso; se for mesmo necessário, avisar o usuário antes e confirmar, e idealmente conferir os 8 perfis em Perfis de Acesso depois pra garantir que nenhuma customização foi perdida.
Não há suíte de testes, lint ou build configurados neste projeto.
### Ambiente de desenvolvimento assistido
-O `.venv` do projeto já tem Python 3.13 + Django 6.0 + DRF + psycopg + python-dotenv + Pillow + nh3 + reportlab + openpyxl + holidays + docling instalados, e há um Postgres local acessível via `.env` — dá pra rodar `makemigrations`/`migrate`/`seed_portal`/`runserver` normalmente por aqui usando `.venv\Scripts\python.exe manage.py ...` (ou ativando o venv primeiro). Isso deixou de ser uma limitação a partir da rodada em que o ambiente ganhou essas ferramentas (ver `plano.md`) — não assumir mais que só é possível revisar o backend estaticamente.
+O `.venv` do projeto já tem Python 3.13 + Django 6.0 + DRF + psycopg + python-dotenv + Pillow + nh3 + reportlab + openpyxl + holidays + docling instalados, e o Postgres acessível via `.env` é o **banco de produção** (não uma cópia de desenvolvimento) — dá pra rodar `makemigrations`/`migrate`/`runserver` normalmente por aqui usando `.venv\Scripts\python.exe manage.py ...` (ou ativando o venv primeiro), mas **nunca `seed_portal`** (ver "Como rodar / testar localmente" acima — recria/ressincroniza perfis já em uso de verdade). Isso deixou de ser uma limitação a partir da rodada em que o ambiente ganhou essas ferramentas (ver `plano.md`) — não assumir mais que só é possível revisar o backend estaticamente.
## Arquitetura
@@ -294,6 +288,8 @@ Isso reaproveita 100% a árvore de permissões que já existe (`renderTree()`/`r
**Cuidado com `seed_portal.py`**: o módulo continua tendo seu próprio `enabled` (visibilidade no menu) e, se estiver em `BASE_KEYS`/`SECTORAL_KEYS` etc., `catalogo.permissions_from_keys()` habilita **todos** os apps de um módulo enabled de uma vez — incluindo todo `*-editar`. Por isso `seed_portal.py` força `permissoes["links-ferramentas"]["apps"]["links-ferramentas-editar"] = False`, `permissoes["links-ferramentas"]["apps"]["acessos-gerais-editar"] = False` e `permissoes["ramais"]["apps"]["ramais-editar"] = False` (+ as outras duas subtelas administráveis de Ramais) explicitamente pra todo perfil que não seja "Integração e Inovação" (código 8, `chaves=None` → todos os apps `True` de propósito), depois de gerar o dict — sem esse override, qualquer perfil com o módulo habilitado nasceria com poder de editar tudo. Ao adicionar uma aplicação nova nesse padrão (dentro de um módulo existente ou não), replicar esse mesmo cuidado no seed.
+**Convenção pra aplicação nova que lide com dado sensível/pessoal (não dado de cliente comum)**: decisão explícita do usuário (rodada em que "Importação de Plano de Saúde - De Paula" foi criada, ver `portal_api/planos_saude/CLAUDE.md`) — daqui pra frente, toda aplicação nova adicionada ao catálogo que trate dado pessoal de colaborador do próprio escritório (em vez de dado de cliente, que é o caso comum do resto do Portal) deve **nascer restrita só ao perfil "Inovação"** (código 8), mesmo padrão já usado por "Não Conformidades" (`permissoes["relatorios"]["apps"]["nao-conformidades"] = False` no bloco de overrides) — nunca herdar a visibilidade ampla padrão de `BASE_KEYS`/`SECTORAL_KEYS`. Outros perfis que precisarem ganham acesso depois, manualmente, pela tela de Perfis de Acesso. **Não retroagir** essa convenção nas aplicações de toggle único que já existiam antes desta decisão (Importação de Plano de Saúde original, Simulação de Custo de Contratação, Indicador de Desempenho) — essas continuam com a visibilidade ampla de sempre, decisão explícita do usuário de não mexer no que já está em uso.
+
**Bug real (rodada 69) — `IntegrityError: duplicate key value violates unique constraint "portal_api_perfilacesso_pkey"` ao criar um perfil pela tela**: `seed_portal.py` semeia `PerfilAcesso` com `codigo` **explícito** (`update_or_create(codigo=dado["codigo"], ...)`, já que os 8 códigos 1–8 são referenciados por número fixo em vários lugares do código — ex.: `codigo == 8` = "Integração e Inovação"). No Postgres, um `INSERT` com PK explícita **nunca avança a sequence** por trás do `AutoField` — então a sequence ficava parada em 1 (seu valor inicial), e o primeiro perfil criado pela tela (`POST /api/perfis/`, sem PK explícita) recebia `codigo=1` do `nextval()`, colidindo com um código já usado pelo seed. `_reset_sequence(model)` (função módulo-level em `seed_portal.py`, roda `SELECT setval(pg_get_serial_sequence(...), MAX(pk))` via SQL puro do Postgres) corrige isso, chamada logo depois do loop de `PERFIS_SEED` — toda vez que `seed_portal` roda, a sequence é realinhada de novo. Se esse erro voltar a aparecer no futuro (ex.: um `loaddata`/`RunPython` de migração também inserindo `PerfilAcesso` com PK explícita sem passar por `seed_portal.py` depois), o comando pra corrigir manualmente é `python manage.py seed_portal` (idempotente, seguro rodar de novo) — não precisa de acesso direto ao banco.
Duas telas administrativas por cima desse modelo (popup "Nova Aplicação" de `perfis-acesso.html`, aba "Usuários do Escritório") estão documentadas em `docs/perfis-usuarios/perfis-usuarios.md`, não aqui.
diff --git a/config/urls.py b/config/urls.py
index dae5c21..44e8c14 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -22,6 +22,7 @@ PAGINAS_FRONTEND = [
"acessos-gerais.html",
"ramais.html",
"importacao-plano-saude.html",
+ "importacao-plano-saude-de-paula.html",
"custo-contratacao.html",
"indicador-desempenho.html",
"nao-conformidades.html",
diff --git a/media/planos_saude_de_paula/operadora/501_Itamed.PDF b/media/planos_saude_de_paula/operadora/501_Itamed.PDF
new file mode 100644
index 0000000..c7ff591
Binary files /dev/null and b/media/planos_saude_de_paula/operadora/501_Itamed.PDF differ
diff --git a/media/planos_saude_de_paula/planilha_padrao/questor_501_2026-08.csv b/media/planos_saude_de_paula/planilha_padrao/questor_501_2026-08.csv
new file mode 100644
index 0000000..e657f59
--- /dev/null
+++ b/media/planos_saude_de_paula/planilha_padrao/questor_501_2026-08.csv
@@ -0,0 +1,15 @@
+CODIGOEMPRESA;NOMEFUNC;CPFFUNC;CODIGOOUTEMP;DATAINICIAL;NOMEDEPENDENTE;CPFDEPENDENTE;VALOREMPRESA;VALOR;DESCRICAO
+501;ALINE KIEPER;044.273.299-69;3755;01/12/2023;;;0;0;
+501;ELIZANGELA DE PAULA KUHN;815.257.459-72;3755;01/12/2023;;;0;0;
+501;ELIZANGELA DE PAULA KUHN;815.257.459-72;3755;01/12/2023;JORGE RICARDO KUHN;522.794.640-04;0;0;
+501;GABRIELA DE MORAES ALVES DA SILVA;106.626.889-45;3755;01/12/2023;JOÃO MARIA CONSTANCIO DE OLIVEIRA;114.128.339-57;0;0;
+501;GABRIELA DE MORAES ALVES DA SILVA;106.626.889-45;3755;01/12/2023;;;0;0;
+501;GABRIELA DE MORAES ALVES DA SILVA;106.626.889-45;3755;01/12/2023;CAIO MORAIS DE OLIVEIRA;165.723.969-16;0;0;
+501;LHAIS VERGILIO DELAVY;084.277.279-09;3755;01/10/2025;;;0;0;
+501;LHAIS VERGILIO DELAVY;084.277.279-09;3755;01/10/2025;LELIO ALEXANDRE CANTERO;072.445.239-71;0;0;
+501;MAICON LUCAS DOS SANTOS PEIXOTO;087.669.999-95;3755;01/12/2023;LUCA DO PRADO PEIXOTO;000.448.679-05;0;0;
+501;MAICON LUCAS DOS SANTOS PEIXOTO;087.669.999-95;3755;01/12/2023;GABRIELA DO PRADO;068.889.729-09;0;0;
+501;MAICON LUCAS DOS SANTOS PEIXOTO;087.669.999-95;3755;01/12/2023;;;0;0;
+501;MARCIO JOSE TEIXEIRA COSTA;007.193.919-97;3755;01/12/2023;KARINE APARECIDA VIEIRA;050.663.469-88;0;0;
+501;MARCIO JOSE TEIXEIRA COSTA;007.193.919-97;3755;01/12/2023;;;0;0;
+501;MARCIO JOSE TEIXEIRA COSTA;007.193.919-97;3755;01/12/2023;MARIA LAURA VIEIRA COSTA;144.420.859-43;0;0;
diff --git a/portal_api/catalogo.py b/portal_api/catalogo.py
index eae9d36..dbc3281 100644
--- a/portal_api/catalogo.py
+++ b/portal_api/catalogo.py
@@ -62,6 +62,7 @@ MODULE_APPS = {
],
"utilitarios": [
{"key": "importacao-plano-saude", "label": "Importação de Plano de Saúde"},
+ {"key": "importacao-plano-saude-de-paula", "label": "Importação de Plano de Saúde - De Paula"},
],
"integracoes": [
{"key": "questor", "label": "Questor"},
diff --git a/portal_api/management/commands/seed_portal.py b/portal_api/management/commands/seed_portal.py
index f831fce..b375806 100644
--- a/portal_api/management/commands/seed_portal.py
+++ b/portal_api/management/commands/seed_portal.py
@@ -173,6 +173,20 @@ class Command(BaseCommand):
# tem acesso por padrão, liberado manualmente pra outros perfis depois
# pela tela de Perfis de Acesso.
permissoes["relatorios"]["apps"]["nao-conformidades"] = False
+ # "utilitarios" está em SECTORAL_KEYS — "importacao-plano-saude-de-paula"
+ # lida com dado pessoal de colaborador do próprio escritório (plano de
+ # saúde), não dado de cliente como o resto de Utilitários, então nasce
+ # restrita: só Integração e Inovação tem acesso por padrão, liberado
+ # manualmente pra outros perfis depois pela tela de Perfis de Acesso —
+ # mesmo padrão de "Não Conformidades" acima. Decisão explícita do
+ # usuário: esse é o padrão a seguir daqui pra frente pra qualquer
+ # aplicação NOVA que lide com dado sensível (nasce restrita a Inovação,
+ # nunca ampla igual ao resto do módulo/setor) — não retroagir nas 3
+ # aplicações de toggle único que já existiam antes desta decisão
+ # (Importação de Plano de Saúde original, Simulação de Custo de
+ # Contratação, Indicador de Desempenho), que continuam com a
+ # visibilidade ampla de sempre.
+ permissoes["utilitarios"]["apps"]["importacao-plano-saude-de-paula"] = False
# `nome` só é gravado na criação — reexecuções não devem reverter uma
# renomeação feita depois pela própria tela de Perfis de Acesso (mesmo
# espírito de "só inicializa na primeira criação" já usado pra
diff --git a/portal_api/migrations/0054_importacaoplanosaudedepaula_and_more.py b/portal_api/migrations/0054_importacaoplanosaudedepaula_and_more.py
new file mode 100644
index 0000000..ac7d4f0
--- /dev/null
+++ b/portal_api/migrations/0054_importacaoplanosaudedepaula_and_more.py
@@ -0,0 +1,173 @@
+# Generated by Django 6.0.7 on 2026-08-28 19:48
+
+import django.db.models.deletion
+import django.db.models.functions.text
+import portal_api.models
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('portal_api', '0053_alter_ncocorrencia_assunto'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ImportacaoPlanoSaudeDePaula',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('operadora', models.CharField(max_length=50, verbose_name='Operadora')),
+ ('nome_operadora', models.CharField(max_length=50, verbose_name='Nome da operadora')),
+ ('tipos_lancamento', models.JSONField(default=list, verbose_name='Tipos de lançamento')),
+ ('custeio_por_tipo', models.JSONField(default=dict, verbose_name='Custeio por tipo')),
+ ('regra_empresa', models.CharField(blank=True, max_length=50, verbose_name='Regra empresa (mensalidade)')),
+ ('planilha_padrao', models.FileField(blank=True, max_length=255, upload_to='planos_saude_de_paula/planilha_padrao/', validators=[portal_api.models.validar_tamanho_arquivo_plano_saude], verbose_name='Planilha padrão (Questor)')),
+ ('competencia', models.DateField(blank=True, null=True, verbose_name='Competência (Questor)')),
+ ('status', models.CharField(choices=[('revisao', 'Em revisão'), ('concluida', 'Concluída')], default='revisao', max_length=20, verbose_name='Status')),
+ ('criado_em', models.DateTimeField(auto_now_add=True)),
+ ('concluida_em', models.DateTimeField(blank=True, null=True, verbose_name='Concluída em')),
+ ('criado_por', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importacoes_plano_saude_de_paula', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'Importação de plano de saúde - De Paula',
+ 'verbose_name_plural': 'Importações de plano de saúde - De Paula',
+ 'ordering': ['-criado_em'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ImportacaoPlanoSaudeDePaulaArquivoOperadora',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('arquivo', models.FileField(max_length=255, upload_to='planos_saude_de_paula/operadora/', validators=[portal_api.models.validar_tamanho_arquivo_plano_saude], verbose_name='Arquivo')),
+ ('ordem', models.PositiveIntegerField(default=0, verbose_name='Ordem')),
+ ('criado_em', models.DateTimeField(auto_now_add=True)),
+ ('importacao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='arquivos_operadora', to='portal_api.importacaoplanosaudedepaula')),
+ ],
+ options={
+ 'verbose_name': 'Arquivo da operadora (importação de plano de saúde - De Paula)',
+ 'verbose_name_plural': 'Arquivos da operadora (importação de plano de saúde - De Paula)',
+ 'ordering': ['ordem', 'id'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ImportacaoPlanoSaudeDePaulaLinha',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('tipo_lancamento', models.CharField(max_length=20, verbose_name='Tipo de lançamento')),
+ ('codigo_empresa', models.CharField(blank=True, max_length=20, verbose_name='Código empresa')),
+ ('nome_func', models.CharField(blank=True, max_length=150, verbose_name='Nome do funcionário')),
+ ('cpf_func', models.CharField(blank=True, max_length=20, verbose_name='CPF do funcionário')),
+ ('codigo_out_emp', models.CharField(blank=True, max_length=20, verbose_name='Código out. emp.')),
+ ('data_inicial', models.CharField(blank=True, max_length=10, verbose_name='Data inicial')),
+ ('nome_dependente', models.CharField(blank=True, max_length=150, verbose_name='Nome do dependente')),
+ ('cpf_dependente', models.CharField(blank=True, max_length=20, verbose_name='CPF do dependente')),
+ ('valor_empresa', models.CharField(blank=True, default='0', max_length=20, validators=[portal_api.models.validar_valor_monetario_br], verbose_name='Valor empresa')),
+ ('valor', models.CharField(blank=True, default='0', max_length=20, validators=[portal_api.models.validar_valor_monetario_br], verbose_name='Valor')),
+ ('descricao', models.CharField(blank=True, max_length=255, verbose_name='Descrição')),
+ ('ordem', models.PositiveIntegerField(default=0, verbose_name='Ordem')),
+ ('importacao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='linhas', to='portal_api.importacaoplanosaudedepaula')),
+ ],
+ options={
+ 'verbose_name': 'Linha de importação de plano de saúde - De Paula',
+ 'verbose_name_plural': 'Linhas de importação de plano de saúde - De Paula',
+ 'ordering': ['tipo_lancamento', 'ordem', 'id'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ImportacaoPlanoSaudeDePaulaAuditoria',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('motivo', models.CharField(max_length=30, verbose_name='Motivo')),
+ ('tipo_lancamento', models.CharField(blank=True, max_length=20, verbose_name='Tipo de lançamento')),
+ ('numero_beneficiario', models.CharField(blank=True, max_length=30, verbose_name='Número do beneficiário')),
+ ('nome', models.CharField(blank=True, max_length=150, verbose_name='Nome')),
+ ('cpf', models.CharField(blank=True, max_length=20, verbose_name='CPF')),
+ ('tipo', models.CharField(blank=True, max_length=5, verbose_name='Tipo')),
+ ('valor', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Valor')),
+ ('detalhe', models.TextField(blank=True, verbose_name='Detalhe/justificativa')),
+ ('resolvida', models.BooleanField(default=False, verbose_name='Resolvida manualmente')),
+ ('importacao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='itens_auditoria', to='portal_api.importacaoplanosaudedepaula')),
+ ('linha_vinculada', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='itens_auditoria_resolvidos', to='portal_api.importacaoplanosaudedepaulalinha')),
+ ],
+ options={
+ 'verbose_name': 'Item de auditoria de importação de plano de saúde - De Paula',
+ 'verbose_name_plural': 'Itens de auditoria de importação de plano de saúde - De Paula',
+ 'ordering': ['id'],
+ },
+ ),
+ migrations.CreateModel(
+ name='RegraCusteioPlanoSaudeDePaula',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('nome', models.CharField(max_length=100, verbose_name='Nome')),
+ ('codigo_empresa', models.CharField(max_length=20, verbose_name='Código da empresa')),
+ ('operadora', models.CharField(max_length=50, verbose_name='Operadora')),
+ ('regra_empresa_chave', models.CharField(blank=True, max_length=50, verbose_name='Regra empresa (mensalidade)')),
+ ('tipos_lancamento', models.JSONField(default=list, verbose_name='Tipos de lançamento')),
+ ('custeio_por_tipo', models.JSONField(default=dict, verbose_name='Custeio por tipo')),
+ ('observacoes', models.TextField(blank=True, verbose_name='Observações')),
+ ('criado_em', models.DateTimeField(auto_now_add=True)),
+ ('atualizado_em', models.DateTimeField(auto_now=True)),
+ ('criado_por', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='regras_custeio_plano_saude_de_paula', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'Regra de custeio de plano de saúde - De Paula',
+ 'verbose_name_plural': 'Regras de custeio de plano de saúde - De Paula',
+ 'ordering': [django.db.models.functions.text.Length('codigo_empresa'), 'codigo_empresa', 'operadora'],
+ 'unique_together': {('codigo_empresa', 'operadora')},
+ },
+ ),
+ migrations.AddField(
+ model_name='importacaoplanosaudedepaula',
+ name='regra_custeio_salva',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importacoes_plano_saude', to='portal_api.regracusteioplanosaudedepaula'),
+ ),
+ migrations.CreateModel(
+ name='VinculoNomeOperadoraDePaula',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('operadora', models.CharField(max_length=50, verbose_name='Operadora')),
+ ('codigo_empresa', models.CharField(max_length=20, verbose_name='Código empresa')),
+ ('nome_arquivo_operadora', models.CharField(help_text='Já normalizado (maiúsculas, sem acento) — é a chave de busca do DE/PARA.', max_length=150, verbose_name='Nome divergente (arquivo da operadora)')),
+ ('nome_func_destino', models.CharField(blank=True, max_length=150, verbose_name='Nome do titular vinculado (planilha padrão)')),
+ ('nome_dependente_destino', models.CharField(blank=True, max_length=150, verbose_name='Nome do dependente vinculado (planilha padrão)')),
+ ('criado_em', models.DateTimeField(auto_now_add=True, verbose_name='Criado em')),
+ ('criado_por', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vinculos_nome_plano_saude_de_paula', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'Vínculo de nome (plano de saúde - De Paula)',
+ 'verbose_name_plural': 'Vínculos de nome (plano de saúde - De Paula)',
+ 'ordering': ['-criado_em'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ImportacaoPlanoSaudeDePaulaAlteracao',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('tipo', models.CharField(choices=[('edicao', 'Edição de valor'), ('inclusao', 'Inclusão de linha'), ('exclusao', 'Exclusão de linha'), ('vinculo_automatico', 'Vínculo automático de nome')], max_length=20, verbose_name='Tipo')),
+ ('tipo_lancamento', models.CharField(blank=True, max_length=20, verbose_name='Tipo de lançamento')),
+ ('campo', models.CharField(blank=True, max_length=30, verbose_name='Campo alterado')),
+ ('valor_anterior', models.TextField(blank=True, verbose_name='Valor anterior')),
+ ('valor_novo', models.TextField(blank=True, verbose_name='Valor novo')),
+ ('dados_linha', models.JSONField(blank=True, default=dict, verbose_name='Dados da linha')),
+ ('criado_em', models.DateTimeField(auto_now_add=True)),
+ ('revertida', models.BooleanField(default=False, verbose_name='Revertida')),
+ ('revertida_em', models.DateTimeField(blank=True, null=True, verbose_name='Revertida em')),
+ ('importacao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alteracoes', to='portal_api.importacaoplanosaudedepaula')),
+ ('usuario', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='alteracoes_plano_saude_de_paula', to=settings.AUTH_USER_MODEL)),
+ ('linha', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='alteracoes', to='portal_api.importacaoplanosaudedepaulalinha')),
+ ('vinculo_nome', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='alteracoes', to='portal_api.vinculonomeoperadoradepaula')),
+ ],
+ options={
+ 'verbose_name': 'Alteração de importação de plano de saúde - De Paula',
+ 'verbose_name_plural': 'Alterações de importação de plano de saúde - De Paula',
+ 'ordering': ['-criado_em'],
+ },
+ ),
+ migrations.AddConstraint(
+ model_name='vinculonomeoperadoradepaula',
+ constraint=models.UniqueConstraint(fields=('operadora', 'codigo_empresa', 'nome_arquivo_operadora'), name='vinculo_nome_operadora_de_paula_unico'),
+ ),
+ ]
diff --git a/portal_api/models.py b/portal_api/models.py
index 1a7ae92..f6a1ef4 100644
--- a/portal_api/models.py
+++ b/portal_api/models.py
@@ -959,6 +959,260 @@ class RegraCusteioPlanoSaude(models.Model):
return self.nome
+class ImportacaoPlanoSaudeDePaula(models.Model):
+ """Mesma ferramenta de `ImportacaoPlanoSaude` acima, mas pro plano de
+ saúde dos próprios colaboradores do escritório De Paula, não de clientes
+ — ver "Importação de Plano de Saúde - De Paula" no CLAUDE.md do pacote
+ `portal_api/planos_saude/`. Tabela própria, sem nenhuma FK cruzando com
+ `ImportacaoPlanoSaude`: o pipeline de extração (`portal_api.planos_saude.*`)
+ é o mesmo, só o histórico/permissão são independentes."""
+
+ STATUS_REVISAO = "revisao"
+ STATUS_CONCLUIDA = "concluida"
+ STATUS_CHOICES = [
+ (STATUS_REVISAO, "Em revisão"),
+ (STATUS_CONCLUIDA, "Concluída"),
+ ]
+
+ operadora = models.CharField("Operadora", max_length=50)
+ nome_operadora = models.CharField("Nome da operadora", max_length=50)
+ tipos_lancamento = models.JSONField("Tipos de lançamento", default=list)
+ custeio_por_tipo = models.JSONField("Custeio por tipo", default=dict)
+ regra_empresa = models.CharField("Regra empresa (mensalidade)", max_length=50, blank=True)
+ regra_custeio_salva = models.ForeignKey(
+ "RegraCusteioPlanoSaudeDePaula",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="importacoes_plano_saude",
+ )
+ planilha_padrao = models.FileField(
+ "Planilha padrão (Questor)",
+ upload_to="planos_saude_de_paula/planilha_padrao/",
+ max_length=255,
+ blank=True,
+ validators=[validar_tamanho_arquivo_plano_saude],
+ )
+ competencia = models.DateField("Competência (Questor)", null=True, blank=True)
+ status = models.CharField("Status", max_length=20, choices=STATUS_CHOICES, default=STATUS_REVISAO)
+ criado_por = models.ForeignKey(
+ Usuario, on_delete=models.SET_NULL, null=True, related_name="importacoes_plano_saude_de_paula"
+ )
+ criado_em = models.DateTimeField(auto_now_add=True)
+ concluida_em = models.DateTimeField("Concluída em", null=True, blank=True)
+
+ class Meta:
+ verbose_name = "Importação de plano de saúde - De Paula"
+ verbose_name_plural = "Importações de plano de saúde - De Paula"
+ ordering = ["-criado_em"]
+
+ def __str__(self) -> str:
+ return f"{self.nome_operadora} ({self.criado_em:%d/%m/%Y})"
+
+
+class ImportacaoPlanoSaudeDePaulaArquivoOperadora(models.Model):
+ """Ver `ImportacaoPlanoSaudeArquivoOperadora` — mesmo papel, escopo De Paula."""
+
+ importacao = models.ForeignKey(
+ ImportacaoPlanoSaudeDePaula, on_delete=models.CASCADE, related_name="arquivos_operadora"
+ )
+ arquivo = models.FileField(
+ "Arquivo",
+ upload_to="planos_saude_de_paula/operadora/",
+ max_length=255,
+ validators=[validar_tamanho_arquivo_plano_saude],
+ )
+ ordem = models.PositiveIntegerField("Ordem", default=0)
+ criado_em = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ verbose_name = "Arquivo da operadora (importação de plano de saúde - De Paula)"
+ verbose_name_plural = "Arquivos da operadora (importação de plano de saúde - De Paula)"
+ ordering = ["ordem", "id"]
+
+ def __str__(self) -> str:
+ return f"{self.importacao} — {self.arquivo.name}"
+
+
+class ImportacaoPlanoSaudeDePaulaLinha(models.Model):
+ """Ver `ImportacaoPlanoSaudeLinha` — mesmo papel, escopo De Paula."""
+
+ importacao = models.ForeignKey(
+ ImportacaoPlanoSaudeDePaula, on_delete=models.CASCADE, related_name="linhas"
+ )
+ tipo_lancamento = models.CharField("Tipo de lançamento", max_length=20)
+ codigo_empresa = models.CharField("Código empresa", max_length=20, blank=True)
+ nome_func = models.CharField("Nome do funcionário", max_length=150, blank=True)
+ cpf_func = models.CharField("CPF do funcionário", max_length=20, blank=True)
+ codigo_out_emp = models.CharField("Código out. emp.", max_length=20, blank=True)
+ data_inicial = models.CharField("Data inicial", max_length=10, blank=True)
+ nome_dependente = models.CharField("Nome do dependente", max_length=150, blank=True)
+ cpf_dependente = models.CharField("CPF do dependente", max_length=20, blank=True)
+ valor_empresa = models.CharField(
+ "Valor empresa", max_length=20, blank=True, default="0", validators=[validar_valor_monetario_br]
+ )
+ valor = models.CharField(
+ "Valor", max_length=20, blank=True, default="0", validators=[validar_valor_monetario_br]
+ )
+ descricao = models.CharField("Descrição", max_length=255, blank=True)
+ ordem = models.PositiveIntegerField("Ordem", default=0)
+
+ class Meta:
+ verbose_name = "Linha de importação de plano de saúde - De Paula"
+ verbose_name_plural = "Linhas de importação de plano de saúde - De Paula"
+ ordering = ["tipo_lancamento", "ordem", "id"]
+
+ def __str__(self) -> str:
+ return f"{self.importacao} → {self.nome_func}"
+
+
+class ImportacaoPlanoSaudeDePaulaAuditoria(models.Model):
+ """Ver `ImportacaoPlanoSaudeAuditoria` — mesmo papel, escopo De Paula."""
+
+ MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")
+
+ importacao = models.ForeignKey(
+ ImportacaoPlanoSaudeDePaula, on_delete=models.CASCADE, related_name="itens_auditoria"
+ )
+ motivo = models.CharField("Motivo", max_length=30)
+ tipo_lancamento = models.CharField("Tipo de lançamento", max_length=20, blank=True)
+ numero_beneficiario = models.CharField("Número do beneficiário", max_length=30, blank=True)
+ nome = models.CharField("Nome", max_length=150, blank=True)
+ cpf = models.CharField("CPF", max_length=20, blank=True)
+ tipo = models.CharField("Tipo", max_length=5, blank=True)
+ valor = models.DecimalField("Valor", max_digits=12, decimal_places=2, default=0)
+ detalhe = models.TextField("Detalhe/justificativa", blank=True)
+ resolvida = models.BooleanField("Resolvida manualmente", default=False)
+ linha_vinculada = models.ForeignKey(
+ ImportacaoPlanoSaudeDePaulaLinha,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="itens_auditoria_resolvidos",
+ )
+
+ class Meta:
+ verbose_name = "Item de auditoria de importação de plano de saúde - De Paula"
+ verbose_name_plural = "Itens de auditoria de importação de plano de saúde - De Paula"
+ ordering = ["id"]
+
+
+class VinculoNomeOperadoraDePaula(models.Model):
+ """Ver `VinculoNomeOperadora` — mesmo "DE/PARA" persistente, escopo De
+ Paula. Tabela própria pra um vínculo resolvido no escopo cliente nunca
+ vazar pro escopo De Paula (ou vice-versa) mesmo que operadora+código de
+ empresa coincidam por acaso."""
+
+ operadora = models.CharField("Operadora", max_length=50)
+ codigo_empresa = models.CharField("Código empresa", max_length=20)
+ nome_arquivo_operadora = models.CharField(
+ "Nome divergente (arquivo da operadora)",
+ max_length=150,
+ help_text="Já normalizado (maiúsculas, sem acento) — é a chave de busca do DE/PARA.",
+ )
+ nome_func_destino = models.CharField("Nome do titular vinculado (planilha padrão)", max_length=150, blank=True)
+ nome_dependente_destino = models.CharField(
+ "Nome do dependente vinculado (planilha padrão)", max_length=150, blank=True
+ )
+ criado_em = models.DateTimeField("Criado em", auto_now_add=True)
+ criado_por = models.ForeignKey(
+ Usuario, on_delete=models.SET_NULL, null=True, related_name="vinculos_nome_plano_saude_de_paula"
+ )
+
+ class Meta:
+ verbose_name = "Vínculo de nome (plano de saúde - De Paula)"
+ verbose_name_plural = "Vínculos de nome (plano de saúde - De Paula)"
+ ordering = ["-criado_em"]
+ constraints = [
+ models.UniqueConstraint(
+ fields=["operadora", "codigo_empresa", "nome_arquivo_operadora"],
+ name="vinculo_nome_operadora_de_paula_unico",
+ )
+ ]
+
+ def __str__(self) -> str:
+ destino = self.nome_func_destino or self.nome_dependente_destino
+ return f"{self.nome_arquivo_operadora} → {destino}"
+
+
+class ImportacaoPlanoSaudeDePaulaAlteracao(models.Model):
+ """Ver `ImportacaoPlanoSaudeAlteracao` — mesmo log de edição/inclusão/
+ exclusão/vínculo automático, escopo De Paula."""
+
+ TIPO_EDICAO = "edicao"
+ TIPO_INCLUSAO = "inclusao"
+ TIPO_EXCLUSAO = "exclusao"
+ TIPO_VINCULO_AUTOMATICO = "vinculo_automatico"
+ TIPO_CHOICES = [
+ (TIPO_EDICAO, "Edição de valor"),
+ (TIPO_INCLUSAO, "Inclusão de linha"),
+ (TIPO_EXCLUSAO, "Exclusão de linha"),
+ (TIPO_VINCULO_AUTOMATICO, "Vínculo automático de nome"),
+ ]
+
+ importacao = models.ForeignKey(
+ ImportacaoPlanoSaudeDePaula, on_delete=models.CASCADE, related_name="alteracoes"
+ )
+ tipo = models.CharField("Tipo", max_length=20, choices=TIPO_CHOICES)
+ linha = models.ForeignKey(
+ ImportacaoPlanoSaudeDePaulaLinha,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="alteracoes",
+ )
+ tipo_lancamento = models.CharField("Tipo de lançamento", max_length=20, blank=True)
+ campo = models.CharField("Campo alterado", max_length=30, blank=True)
+ valor_anterior = models.TextField("Valor anterior", blank=True)
+ valor_novo = models.TextField("Valor novo", blank=True)
+ dados_linha = models.JSONField("Dados da linha", default=dict, blank=True)
+ vinculo_nome = models.ForeignKey(
+ VinculoNomeOperadoraDePaula, on_delete=models.SET_NULL, null=True, blank=True, related_name="alteracoes"
+ )
+ usuario = models.ForeignKey(
+ Usuario, on_delete=models.SET_NULL, null=True, related_name="alteracoes_plano_saude_de_paula"
+ )
+ criado_em = models.DateTimeField(auto_now_add=True)
+ revertida = models.BooleanField("Revertida", default=False)
+ revertida_em = models.DateTimeField("Revertida em", null=True, blank=True)
+
+ class Meta:
+ verbose_name = "Alteração de importação de plano de saúde - De Paula"
+ verbose_name_plural = "Alterações de importação de plano de saúde - De Paula"
+ ordering = ["-criado_em"]
+
+ def __str__(self) -> str:
+ return f"{self.importacao} — {self.get_tipo_display()}"
+
+
+class RegraCusteioPlanoSaudeDePaula(models.Model):
+ """Ver `RegraCusteioPlanoSaude` — mesmo cadastro de regra de custeio por
+ empresa+operadora, escopo De Paula. Permissão própria
+ (`PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")`)."""
+
+ nome = models.CharField("Nome", max_length=100)
+ codigo_empresa = models.CharField("Código da empresa", max_length=20)
+ operadora = models.CharField("Operadora", max_length=50)
+ regra_empresa_chave = models.CharField("Regra empresa (mensalidade)", max_length=50, blank=True)
+ tipos_lancamento = models.JSONField("Tipos de lançamento", default=list)
+ custeio_por_tipo = models.JSONField("Custeio por tipo", default=dict)
+ observacoes = models.TextField("Observações", blank=True)
+ criado_por = models.ForeignKey(
+ Usuario, on_delete=models.SET_NULL, null=True, related_name="regras_custeio_plano_saude_de_paula"
+ )
+ criado_em = models.DateTimeField(auto_now_add=True)
+ atualizado_em = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ verbose_name = "Regra de custeio de plano de saúde - De Paula"
+ verbose_name_plural = "Regras de custeio de plano de saúde - De Paula"
+ unique_together = [["codigo_empresa", "operadora"]]
+ ordering = [Length("codigo_empresa"), "codigo_empresa", "operadora"]
+
+ def __str__(self) -> str:
+ return self.nome
+
+
class IndicadorDepartamento(models.Model):
"""Departamento organizacional usado pelo Indicador de Desempenho — cada
um tem seu próprio cadastro de critérios (`IndicadorCriterio`) e
diff --git a/portal_api/serializers.py b/portal_api/serializers.py
index b44be5e..75dd788 100644
--- a/portal_api/serializers.py
+++ b/portal_api/serializers.py
@@ -24,6 +24,10 @@ from .models import (
ImportacaoPlanoSaudeAlteracao,
ImportacaoPlanoSaudeAuditoria,
ImportacaoPlanoSaudeLinha,
+ ImportacaoPlanoSaudeDePaula,
+ ImportacaoPlanoSaudeDePaulaAlteracao,
+ ImportacaoPlanoSaudeDePaulaAuditoria,
+ ImportacaoPlanoSaudeDePaulaLinha,
IndicadorApuracao,
IndicadorApuracaoColaborador,
IndicadorApuracaoEmpresa,
@@ -44,6 +48,7 @@ from .models import (
Ramal,
RamalAusencia,
RegraCusteioPlanoSaude,
+ RegraCusteioPlanoSaudeDePaula,
TelefoneExterno,
Usuario,
WidgetUsuario,
@@ -1152,6 +1157,475 @@ class RegraCusteioPlanoSaudeSerializer(serializers.ModelSerializer):
return attrs
+# --- "Importação de Plano de Saúde - De Paula" ---------------------------
+# Mesmos serializers acima, apontando pros models "DePaula" (tabelas
+# próprias, plano de saúde dos colaboradores do escritório, não de
+# clientes — ver CLAUDE.md em portal_api/planos_saude/). Os helpers
+# module-level (_monta_regra_custeio, _valor_custeio_para_texto_br,
+# _nome_operadora_sem_codigo, _nome_empresa_cacheado,
+# _codigo_empresa_da_importacao) são genéricos o bastante (operam por
+# duck typing sobre o objeto recebido) pra serem reaproveitados sem
+# duplicação.
+
+
+class ImportacaoPlanoSaudeDePaulaCreateSerializer(serializers.Serializer):
+ """Ver `ImportacaoPlanoSaudeCreateSerializer` — mesma validação de
+ entrada, escopo De Paula."""
+
+ operadora = serializers.ChoiceField(choices=list(OPERADORAS.keys()))
+ planilha_padrao = serializers.FileField(required=False)
+ competencia = serializers.DateField(required=False, allow_null=True)
+ arquivo_operadora = serializers.ListField(child=serializers.FileField(), allow_empty=False)
+ tipos_lancamento = serializers.CharField()
+ regra_empresa = serializers.CharField(required=False, allow_blank=True)
+ regra_custeio_salva = serializers.PrimaryKeyRelatedField(
+ queryset=RegraCusteioPlanoSaudeDePaula.objects.all(), required=False, allow_null=True
+ )
+ custeio_mensalidade_titular = serializers.ChoiceField(choices=CUSTEIOS_VALIDOS, required=False)
+ custeio_mensalidade_dependente = serializers.ChoiceField(choices=CUSTEIOS_VALIDOS, required=False)
+ custeio_coparticipacao_titular = serializers.ChoiceField(choices=CUSTEIOS_VALIDOS, required=False)
+ custeio_coparticipacao_dependente = serializers.ChoiceField(choices=CUSTEIOS_VALIDOS, required=False)
+ limite_valor_mensalidade_titular = serializers.CharField(required=False, allow_blank=True)
+ percentual_mensalidade_titular = serializers.CharField(required=False, allow_blank=True)
+ limite_desconto_empregado_mensalidade_titular = serializers.CharField(required=False, allow_blank=True)
+ limite_valor_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
+ percentual_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
+ limite_desconto_empregado_mensalidade_dependente = serializers.CharField(required=False, allow_blank=True)
+ limite_valor_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True)
+ percentual_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True)
+ limite_desconto_empregado_coparticipacao_titular = serializers.CharField(required=False, allow_blank=True)
+ limite_valor_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
+ percentual_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
+ limite_desconto_empregado_coparticipacao_dependente = serializers.CharField(required=False, allow_blank=True)
+
+ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
+ tipos = [t.strip() for t in attrs["tipos_lancamento"].split(",") if t.strip()]
+ if not tipos:
+ raise serializers.ValidationError({"tipos_lancamento": "Selecione ao menos um tipo de importação."})
+
+ regra_empresa_key = (attrs.get("regra_empresa") or "").strip()
+ regra_empresa_tipos: tuple[str, ...] = ()
+ if regra_empresa_key:
+ regra_re = REGRAS_EMPRESA.get(regra_empresa_key)
+ if regra_re is None:
+ raise serializers.ValidationError({"regra_empresa": f"Regra empresa desconhecida: {regra_empresa_key}"})
+ regra_empresa_tipos = tuple(t for t in regra_re.get("tipos_lancamento", ("mensalidade",)) if t in tipos)
+ if not regra_empresa_tipos:
+ raise serializers.ValidationError(
+ {
+ "regra_empresa": (
+ f"\"{regra_re['label']}\" cobre {'/'.join(regra_re.get('tipos_lancamento', ()))}, "
+ "mas nenhum desses tipos de importação está selecionado."
+ )
+ }
+ )
+ parser_instancia = OPERADORAS[attrs["operadora"]]["parser"]()
+ casamento_exigido = regra_re.get("chave_casamento", "nome")
+ for tipo in regra_empresa_tipos:
+ if parser_instancia.chave_casamento_para_tipo(tipo) != casamento_exigido:
+ raise serializers.ValidationError(
+ {
+ "regra_empresa": (
+ f"Esta operadora não é compatível com \"{regra_re['label']}\" "
+ f"(exige casamento por {casamento_exigido!r})."
+ )
+ }
+ )
+
+ custeio_por_tipo: dict[str, Any] = {}
+ for tipo in tipos:
+ if tipo not in TIPOS_LANCAMENTO_VALIDOS:
+ raise serializers.ValidationError({"tipos_lancamento": f"Tipo de importação inválido: {tipo}"})
+
+ if tipo in regra_empresa_tipos:
+ custeio_por_tipo[tipo] = {}
+ continue
+
+ custeio_por_pessoa: dict[str, Any] = {}
+ for pessoa in TIPOS_PESSOA_VALIDOS:
+ chave = f"{tipo}_{pessoa}"
+ custeio_por_pessoa[pessoa] = _monta_regra_custeio(
+ chave,
+ attrs.get(f"custeio_{chave}"),
+ attrs.get(f"limite_valor_{chave}", ""),
+ attrs.get(f"percentual_{chave}", ""),
+ attrs.get(f"limite_desconto_empregado_{chave}", ""),
+ )
+ custeio_por_tipo[tipo] = custeio_por_pessoa
+
+ attrs["tipos_lancamento_lista"] = tipos
+ attrs["custeio_por_tipo"] = custeio_por_tipo
+ attrs["regra_empresa"] = regra_empresa_key
+
+ planilha_padrao = attrs.get("planilha_padrao")
+ competencia = attrs.get("competencia")
+ if planilha_padrao and competencia:
+ raise serializers.ValidationError(
+ {"competencia": "Anexe a planilha manualmente ou informe a competência para buscar no Questor — não os dois."}
+ )
+ if not planilha_padrao and not competencia:
+ raise serializers.ValidationError(
+ {"planilha_padrao": "Anexe a planilha padrão ou informe a competência para buscar no Questor."}
+ )
+
+ if competencia:
+ regra_custeio_salva = attrs.get("regra_custeio_salva")
+ if not regra_custeio_salva or not regra_custeio_salva.codigo_empresa:
+ raise serializers.ValidationError(
+ {"competencia": "Buscar a planilha no Questor exige selecionar a empresa (regra de custeio cadastrada)."}
+ )
+
+ return attrs
+
+
+class ImportacaoPlanoSaudeDePaulaLinhaSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ImportacaoPlanoSaudeDePaulaLinha
+ fields = [
+ "id",
+ "tipo_lancamento",
+ "codigo_empresa",
+ "nome_func",
+ "cpf_func",
+ "codigo_out_emp",
+ "data_inicial",
+ "nome_dependente",
+ "cpf_dependente",
+ "valor_empresa",
+ "valor",
+ "descricao",
+ ]
+ read_only_fields = ["id", "tipo_lancamento"]
+
+
+class ImportacaoPlanoSaudeDePaulaLinhaCreateSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ImportacaoPlanoSaudeDePaulaLinha
+ fields = [
+ "id",
+ "importacao",
+ "tipo_lancamento",
+ "codigo_empresa",
+ "nome_func",
+ "cpf_func",
+ "codigo_out_emp",
+ "data_inicial",
+ "nome_dependente",
+ "cpf_dependente",
+ "valor_empresa",
+ "valor",
+ "descricao",
+ ]
+ read_only_fields = ["id"]
+
+ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
+ importacao = attrs.get("importacao")
+ tipo = attrs.get("tipo_lancamento")
+ if tipo not in TIPOS_LANCAMENTO_VALIDOS:
+ raise serializers.ValidationError({"tipo_lancamento": f"Tipo de importação inválido: {tipo}"})
+ if importacao is not None and tipo not in (importacao.tipos_lancamento or []):
+ raise serializers.ValidationError(
+ {"tipo_lancamento": "Esse tipo de lançamento não faz parte desta importação."}
+ )
+ return attrs
+
+
+class ImportacaoPlanoSaudeDePaulaAuditoriaSerializer(serializers.ModelSerializer):
+ linha_vinculada_nome = serializers.SerializerMethodField()
+
+ class Meta:
+ model = ImportacaoPlanoSaudeDePaulaAuditoria
+ fields = [
+ "id",
+ "motivo",
+ "tipo_lancamento",
+ "numero_beneficiario",
+ "nome",
+ "cpf",
+ "tipo",
+ "valor",
+ "detalhe",
+ "resolvida",
+ "linha_vinculada",
+ "linha_vinculada_nome",
+ ]
+ read_only_fields = ["resolvida", "linha_vinculada"]
+
+ def get_linha_vinculada_nome(self, obj: ImportacaoPlanoSaudeDePaulaAuditoria) -> str | None:
+ if not obj.linha_vinculada:
+ return None
+ return obj.linha_vinculada.nome_dependente or obj.linha_vinculada.nome_func
+
+
+class ImportacaoPlanoSaudeDePaulaAlteracaoSerializer(serializers.ModelSerializer):
+ usuario_nome = serializers.CharField(source="usuario.nome", read_only=True, default=None)
+ linha_nome = serializers.SerializerMethodField()
+ vinculo_nome_destino = serializers.SerializerMethodField()
+
+ class Meta:
+ model = ImportacaoPlanoSaudeDePaulaAlteracao
+ fields = [
+ "id",
+ "tipo",
+ "linha",
+ "linha_nome",
+ "tipo_lancamento",
+ "campo",
+ "valor_anterior",
+ "valor_novo",
+ "dados_linha",
+ "vinculo_nome",
+ "vinculo_nome_destino",
+ "usuario_nome",
+ "criado_em",
+ "revertida",
+ "revertida_em",
+ ]
+ read_only_fields = fields
+
+ def get_linha_nome(self, obj: ImportacaoPlanoSaudeDePaulaAlteracao) -> str | None:
+ dados = obj.dados_linha or {}
+ if obj.linha_id:
+ return obj.linha.nome_dependente or obj.linha.nome_func or None
+ return dados.get("nome_dependente") or dados.get("nome_func") or None
+
+ def get_vinculo_nome_destino(self, obj: ImportacaoPlanoSaudeDePaulaAlteracao) -> str | None:
+ if not obj.vinculo_nome_id:
+ return None
+ vinculo = obj.vinculo_nome
+ return vinculo.nome_func_destino or vinculo.nome_dependente_destino or None
+
+
+class ImportacaoPlanoSaudeDePaulaListSerializer(serializers.ModelSerializer):
+ criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
+ codigo_empresa = serializers.SerializerMethodField()
+
+ class Meta:
+ model = ImportacaoPlanoSaudeDePaula
+ fields = [
+ "id",
+ "nome_operadora",
+ "codigo_empresa",
+ "competencia",
+ "tipos_lancamento",
+ "custeio_por_tipo",
+ "status",
+ "criado_por_nome",
+ "criado_em",
+ "concluida_em",
+ ]
+
+ def get_codigo_empresa(self, obj: ImportacaoPlanoSaudeDePaula) -> str:
+ return _codigo_empresa_da_importacao(obj)
+
+
+class ImportacaoPlanoSaudeDePaulaDetailSerializer(serializers.ModelSerializer):
+ criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
+ linhas = ImportacaoPlanoSaudeDePaulaLinhaSerializer(many=True, read_only=True)
+ itens_auditoria = ImportacaoPlanoSaudeDePaulaAuditoriaSerializer(many=True, read_only=True)
+ alteracoes = ImportacaoPlanoSaudeDePaulaAlteracaoSerializer(many=True, read_only=True)
+ resumo_por_tipo = serializers.SerializerMethodField()
+ codigo_empresa = serializers.SerializerMethodField()
+ nome_empresa = serializers.SerializerMethodField()
+ regra_empresa_label = serializers.SerializerMethodField()
+ regra_empresa_observacoes = serializers.SerializerMethodField()
+ regra_custeio_salva_nome = serializers.CharField(source="regra_custeio_salva.nome", read_only=True, default=None)
+ regra_custeio_salva_observacoes = serializers.CharField(
+ source="regra_custeio_salva.observacoes", read_only=True, default=None
+ )
+
+ class Meta:
+ model = ImportacaoPlanoSaudeDePaula
+ fields = [
+ "id",
+ "operadora",
+ "nome_operadora",
+ "codigo_empresa",
+ "nome_empresa",
+ "competencia",
+ "tipos_lancamento",
+ "custeio_por_tipo",
+ "regra_empresa",
+ "regra_empresa_label",
+ "regra_empresa_observacoes",
+ "regra_custeio_salva",
+ "regra_custeio_salva_nome",
+ "regra_custeio_salva_observacoes",
+ "status",
+ "criado_por_nome",
+ "criado_em",
+ "concluida_em",
+ "linhas",
+ "itens_auditoria",
+ "alteracoes",
+ "resumo_por_tipo",
+ ]
+
+ def get_codigo_empresa(self, obj: ImportacaoPlanoSaudeDePaula) -> str:
+ return _codigo_empresa_da_importacao(obj)
+
+ def get_nome_empresa(self, obj: ImportacaoPlanoSaudeDePaula) -> str | None:
+ return _nome_empresa_cacheado(self.get_codigo_empresa(obj))
+
+ def get_regra_empresa_label(self, obj: ImportacaoPlanoSaudeDePaula) -> str | None:
+ regra = REGRAS_EMPRESA.get(obj.regra_empresa)
+ return regra["label"] if regra else None
+
+ def get_regra_empresa_observacoes(self, obj: ImportacaoPlanoSaudeDePaula) -> str | None:
+ regra = REGRAS_EMPRESA.get(obj.regra_empresa)
+ observacoes = (regra or {}).get("observacoes")
+ return observacoes or None
+
+ def get_resumo_por_tipo(self, obj: ImportacaoPlanoSaudeDePaula) -> list[dict[str, Any]]:
+ resumo = []
+ for tipo in obj.tipos_lancamento:
+ linhas_do_tipo = [l for l in obj.linhas.all() if l.tipo_lancamento == tipo]
+ lancadas = sum(1 for l in linhas_do_tipo if l.valor != "0" or l.valor_empresa != "0")
+ auditoria_do_tipo = sum(
+ 1 for item in obj.itens_auditoria.all() if item.tipo_lancamento == tipo and not item.resolvida
+ )
+ resumo.append({
+ "tipo_lancamento": tipo,
+ "total_linhas": len(linhas_do_tipo),
+ "total_lancadas": lancadas,
+ "total_auditoria": auditoria_do_tipo,
+ })
+ return resumo
+
+
+class RegraCusteioPlanoSaudeDePaulaSerializer(serializers.ModelSerializer):
+ """Ver `RegraCusteioPlanoSaudeSerializer` — mesma validação, escopo De
+ Paula."""
+
+ criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
+ nome = serializers.CharField(read_only=True)
+ nome_empresa = serializers.SerializerMethodField()
+
+ class Meta:
+ model = RegraCusteioPlanoSaudeDePaula
+ fields = [
+ "id",
+ "nome",
+ "codigo_empresa",
+ "nome_empresa",
+ "operadora",
+ "regra_empresa_chave",
+ "tipos_lancamento",
+ "custeio_por_tipo",
+ "observacoes",
+ "criado_por_nome",
+ "criado_em",
+ "atualizado_em",
+ ]
+ read_only_fields = ["id", "criado_por_nome", "criado_em", "atualizado_em"]
+ validators = [
+ UniqueTogetherValidator(
+ queryset=RegraCusteioPlanoSaudeDePaula.objects.all(),
+ fields=["codigo_empresa", "operadora"],
+ message="Já existe uma regra de custeio cadastrada para essa empresa com essa operadora.",
+ )
+ ]
+
+ def get_nome_empresa(self, obj: RegraCusteioPlanoSaudeDePaula) -> str | None:
+ return resolve_nome_empresa(obj.codigo_empresa)
+
+ def validate_codigo_empresa(self, value: str) -> str:
+ value = normalizar_codigo_empresa(value)
+ if not value:
+ raise serializers.ValidationError("Informe o código da empresa.")
+ return value
+
+ def validate_operadora(self, value: str) -> str:
+ value = (value or "").strip()
+ if value not in OPERADORAS:
+ raise serializers.ValidationError(f"Operadora inválida: {value}")
+ return value
+
+ def validate_regra_empresa_chave(self, value: str) -> str:
+ value = (value or "").strip()
+ if value and value not in REGRAS_EMPRESA:
+ raise serializers.ValidationError(f"Regra empresa desconhecida: {value}")
+ return value
+
+ def validate_tipos_lancamento(self, value: Any) -> list[str]:
+ if not isinstance(value, list) or not value:
+ raise serializers.ValidationError("Selecione ao menos um tipo de importação.")
+ for tipo in value:
+ if tipo not in TIPOS_LANCAMENTO_VALIDOS:
+ raise serializers.ValidationError(f"Tipo de importação inválido: {tipo}")
+ return value
+
+ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
+ tipos = attrs.get("tipos_lancamento")
+ if tipos is None:
+ tipos = self.instance.tipos_lancamento if self.instance else []
+ codigo_empresa = attrs.get("codigo_empresa", self.instance.codigo_empresa if self.instance else None)
+ operadora_key = attrs.get("operadora", self.instance.operadora if self.instance else None)
+ regra_empresa_chave = attrs.get(
+ "regra_empresa_chave", self.instance.regra_empresa_chave if self.instance else ""
+ )
+ custeio_bruto = attrs.get("custeio_por_tipo")
+ if custeio_bruto is None:
+ custeio_bruto = self.instance.custeio_por_tipo if self.instance else {}
+ if not isinstance(custeio_bruto, dict):
+ raise serializers.ValidationError({"custeio_por_tipo": "Formato inválido."})
+
+ regra_empresa_tipos: tuple[str, ...] = ()
+ if regra_empresa_chave:
+ regra_re = REGRAS_EMPRESA[regra_empresa_chave]
+ regra_empresa_tipos = tuple(t for t in regra_re.get("tipos_lancamento", ("mensalidade",)) if t in tipos)
+ if not regra_empresa_tipos:
+ raise serializers.ValidationError(
+ {
+ "regra_empresa_chave": (
+ f"\"{regra_re['label']}\" cobre {'/'.join(regra_re.get('tipos_lancamento', ()))}, "
+ "mas nenhum desses tipos de importação está selecionado."
+ )
+ }
+ )
+ if regra_re["codigo_empresa"] != codigo_empresa:
+ raise serializers.ValidationError(
+ {
+ "regra_empresa_chave": (
+ f"Esta regra especial foi cadastrada para a empresa código "
+ f"{regra_re['codigo_empresa']}, não para {codigo_empresa}."
+ )
+ }
+ )
+ if regra_re["operadora"] != operadora_key:
+ raise serializers.ValidationError(
+ {"regra_empresa_chave": "Esta regra especial foi cadastrada para outra operadora."}
+ )
+
+ custeio_validado: dict[str, Any] = {}
+ for tipo in tipos:
+ if tipo in regra_empresa_tipos:
+ custeio_validado[tipo] = {}
+ continue
+
+ por_tipo = custeio_bruto.get(tipo) or {}
+ if not isinstance(por_tipo, dict):
+ raise serializers.ValidationError({"custeio_por_tipo": f"Formato inválido para o tipo {tipo}."})
+ custeio_por_pessoa: dict[str, Any] = {}
+ for pessoa in TIPOS_PESSOA_VALIDOS:
+ chave = f"{tipo}_{pessoa}"
+ entrada = por_tipo.get(pessoa) or {}
+ if not isinstance(entrada, dict):
+ entrada = {}
+ custeio_por_pessoa[pessoa] = _monta_regra_custeio(
+ chave,
+ entrada.get("modo"),
+ _valor_custeio_para_texto_br(entrada.get("limite_valor")),
+ _valor_custeio_para_texto_br(entrada.get("percentual")),
+ _valor_custeio_para_texto_br(entrada.get("limite_desconto_empregado")),
+ )
+ custeio_validado[tipo] = custeio_por_pessoa
+
+ attrs["custeio_por_tipo"] = custeio_validado
+ attrs["nome"] = f"{codigo_empresa} - {_nome_operadora_sem_codigo(operadora_key)}"
+ return attrs
+
+
class IndicadorPercentualTipoSerializer(serializers.ModelSerializer):
tipo_label = serializers.CharField(source="get_tipo_display", read_only=True)
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
diff --git a/portal_api/urls.py b/portal_api/urls.py
index 8b78f27..4195269 100644
--- a/portal_api/urls.py
+++ b/portal_api/urls.py
@@ -39,6 +39,31 @@ router.register(
router.register(
"regras-custeio-plano-saude", views.RegraCusteioPlanoSaudeViewSet, basename="regra-custeio-plano-saude"
)
+router.register(
+ "importacoes-plano-saude-de-paula",
+ views.ImportacaoPlanoSaudeDePaulaViewSet,
+ basename="importacao-plano-saude-de-paula",
+)
+router.register(
+ "importacoes-plano-saude-de-paula-linhas",
+ views.ImportacaoPlanoSaudeDePaulaLinhaViewSet,
+ basename="importacao-plano-saude-de-paula-linha",
+)
+router.register(
+ "importacoes-plano-saude-de-paula-auditoria",
+ views.ImportacaoPlanoSaudeDePaulaAuditoriaViewSet,
+ basename="importacao-plano-saude-de-paula-auditoria",
+)
+router.register(
+ "importacoes-plano-saude-de-paula-alteracoes",
+ views.ImportacaoPlanoSaudeDePaulaAlteracaoViewSet,
+ basename="importacao-plano-saude-de-paula-alteracao",
+)
+router.register(
+ "regras-custeio-plano-saude-de-paula",
+ views.RegraCusteioPlanoSaudeDePaulaViewSet,
+ basename="regra-custeio-plano-saude-de-paula",
+)
router.register(
"indicadores-percentuais-tipo", views.IndicadorPercentualTipoViewSet, basename="indicador-percentual-tipo"
)
diff --git a/portal_api/views.py b/portal_api/views.py
index b199459..7f223fc 100644
--- a/portal_api/views.py
+++ b/portal_api/views.py
@@ -46,6 +46,11 @@ from .models import (
ImportacaoPlanoSaudeArquivoOperadora,
ImportacaoPlanoSaudeAuditoria,
ImportacaoPlanoSaudeLinha,
+ ImportacaoPlanoSaudeDePaula,
+ ImportacaoPlanoSaudeDePaulaAlteracao,
+ ImportacaoPlanoSaudeDePaulaArquivoOperadora,
+ ImportacaoPlanoSaudeDePaulaAuditoria,
+ ImportacaoPlanoSaudeDePaulaLinha,
IndicadorApuracao,
IndicadorApuracaoColaborador,
IndicadorApuracaoEmpresa,
@@ -66,9 +71,11 @@ from .models import (
Ramal,
RamalAusencia,
RegraCusteioPlanoSaude,
+ RegraCusteioPlanoSaudeDePaula,
TelefoneExterno,
Usuario,
VinculoNomeOperadora,
+ VinculoNomeOperadoraDePaula,
WidgetUsuario,
)
from .indicadores import calculo as indicadores_calculo
@@ -106,6 +113,13 @@ from .serializers import (
ImportacaoPlanoSaudeLinhaCreateSerializer,
ImportacaoPlanoSaudeLinhaSerializer,
ImportacaoPlanoSaudeListSerializer,
+ ImportacaoPlanoSaudeDePaulaAlteracaoSerializer,
+ ImportacaoPlanoSaudeDePaulaAuditoriaSerializer,
+ ImportacaoPlanoSaudeDePaulaCreateSerializer,
+ ImportacaoPlanoSaudeDePaulaDetailSerializer,
+ ImportacaoPlanoSaudeDePaulaLinhaCreateSerializer,
+ ImportacaoPlanoSaudeDePaulaLinhaSerializer,
+ ImportacaoPlanoSaudeDePaulaListSerializer,
IndicadorApuracaoAjusteDepartamentoSerializer,
IndicadorApuracaoAjusteGrupoSerializer,
IndicadorApuracaoAjusteHonorarioEmpresaSerializer,
@@ -141,6 +155,7 @@ from .serializers import (
RamalAusenciaSerializer,
RamalSerializer,
RegraCusteioPlanoSaudeSerializer,
+ RegraCusteioPlanoSaudeDePaulaSerializer,
SimulacaoCustoContratacaoEntradaSerializer,
TelefoneExternoSerializer,
UsuarioListSerializer,
@@ -1496,6 +1511,543 @@ class RegraCusteioPlanoSaudeViewSet(viewsets.ModelViewSet):
return Response({"codigo_empresa": codigo, "nome_empresa": resolve_nome_empresa(codigo)})
+# --- "Importação de Plano de Saúde - De Paula" ----------------------------
+# Mesmas views acima, sobre os models "DePaula" (tabelas próprias, plano de
+# saúde dos colaboradores do escritório, não de clientes — ver CLAUDE.md em
+# portal_api/planos_saude/), com permissão independente
+# (PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")). O
+# pipeline de extração (planos_saude_pipeline/matcher/regras_empresa) e os
+# helpers puros (_salva_arquivo_temporario, _valida_planilha_padrao,
+# _valida_arquivo_operadora, _monta_csv_linhas_plano_saude,
+# _snapshot_linha_plano_saude) são reaproveitados sem duplicação.
+
+
+def _garante_importacao_de_paula_em_revisao(importacao: ImportacaoPlanoSaudeDePaula) -> None:
+ """Ver `_garante_importacao_em_revisao` — mesma trava, escopo De Paula."""
+ if importacao.status == ImportacaoPlanoSaudeDePaula.STATUS_CONCLUIDA:
+ raise ValidationError(
+ {"detail": 'Esta importação já foi concluída. Clique em "Editar" para reabri-la antes de alterar.'}
+ )
+
+
+def _carrega_vinculos_por_nome_de_paula(
+ operadora_key: str, linhas_sistema_template: list[LinhaSistema]
+) -> dict[str, VinculoNomePuro]:
+ """Ver `_carrega_vinculos_por_nome` — mesmo DE/PARA, lendo de
+ `VinculoNomeOperadoraDePaula` (tabela própria, nunca cruza com o DE/PARA
+ da aplicação original)."""
+ codigos = {
+ normalizar_codigo_empresa(linha.codigo_empresa)
+ for linha in linhas_sistema_template
+ if linha.codigo_empresa
+ }
+ if not codigos:
+ return {}
+ vinculos = VinculoNomeOperadoraDePaula.objects.filter(operadora=operadora_key)
+ return {
+ vinculo.nome_arquivo_operadora: VinculoNomePuro(
+ id=vinculo.id,
+ nome_func_destino=vinculo.nome_func_destino,
+ nome_dependente_destino=vinculo.nome_dependente_destino,
+ )
+ for vinculo in vinculos
+ if normalizar_codigo_empresa(vinculo.codigo_empresa) in codigos
+ }
+
+
+class ImportacaoPlanoSaudeDePaulaViewSet(viewsets.ModelViewSet):
+ """Ver `ImportacaoPlanoSaudeViewSet` — mesmo fluxo, escopo De Paula."""
+
+ http_method_names = ["get", "post", "delete", "head", "options"]
+ queryset = ImportacaoPlanoSaudeDePaula.objects.all().order_by("-criado_em")
+
+ def get_permissions(self) -> list[BasePermission]:
+ return [PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")]
+
+ def perform_destroy(self, instance: ImportacaoPlanoSaudeDePaula) -> None:
+ for arquivo in instance.arquivos_operadora.all():
+ arquivo.arquivo.delete(save=False)
+ instance.planilha_padrao.delete(save=False)
+ instance.delete()
+
+ def get_serializer_class(self) -> type[ModelSerializer]:
+ if self.action == "list":
+ return ImportacaoPlanoSaudeDePaulaListSerializer
+ return ImportacaoPlanoSaudeDePaulaDetailSerializer
+
+ @action(detail=False, methods=["get"])
+ def operadoras(self, request: Request) -> Response:
+ return Response(planos_saude_pipeline.lista_operadoras())
+
+ @action(detail=False, methods=["get"], url_path="regras-empresa")
+ def regras_empresa(self, request: Request) -> Response:
+ return Response(planos_saude_regras_empresa.lista_regras_empresa())
+
+ @action(detail=False, methods=["post"], url_path="validar-arquivo")
+ def validar_arquivo(self, request: Request) -> Response:
+ arquivo = request.FILES.get("arquivo")
+ if not arquivo:
+ raise ValidationError({"arquivo": "Envie um arquivo."})
+
+ tipo = request.data.get("tipo")
+ if tipo == "planilha":
+ return Response(_valida_planilha_padrao(arquivo))
+ if tipo == "operadora":
+ operadora_key = request.data.get("operadora")
+ if operadora_key not in planos_saude_pipeline.OPERADORAS:
+ raise ValidationError({"operadora": "Selecione a operadora antes de anexar o arquivo."})
+ return Response(_valida_arquivo_operadora(arquivo, operadora_key))
+ raise ValidationError({"tipo": "Informe 'planilha' ou 'operadora'."})
+
+ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ entrada = ImportacaoPlanoSaudeDePaulaCreateSerializer(data=request.data)
+ entrada.is_valid(raise_exception=True)
+ dados = entrada.validated_data
+ tipos = dados["tipos_lancamento_lista"]
+ custeio_por_tipo = dados["custeio_por_tipo"]
+ operadora_key = dados["operadora"]
+ operadora_info = planos_saude_pipeline.OPERADORAS[operadora_key]
+ regra_custeio_obj = dados.get("regra_custeio_salva")
+ competencia = dados.get("competencia")
+
+ planilha_padrao_arquivo = dados.get("planilha_padrao")
+ linhas_sistema_template = None
+ planilha_padrao_gerada = None
+ if not planilha_padrao_arquivo:
+ codigo_empresa_regra = normalizar_codigo_empresa(regra_custeio_obj.codigo_empresa)
+ codigo_operadora = operadora_info["codigo_operadora"]
+ try:
+ linhas_sistema_template = busca_linhas_questor(codigo_empresa_regra, codigo_operadora, competencia)
+ except Exception:
+ return Response(
+ {
+ "detail": (
+ "Não foi possível buscar a planilha padrão no Questor agora. "
+ "Tente novamente em alguns instantes ou anexe a planilha manualmente."
+ )
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ if not linhas_sistema_template:
+ return Response(
+ {
+ "detail": (
+ f"Nenhum beneficiário encontrado no Questor para a empresa {regra_custeio_obj.codigo_empresa} "
+ f"com a operadora {operadora_info['nome']} na competência informada."
+ )
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ planilha_padrao_gerada = ContentFile(
+ linhas_para_csv_bytes(linhas_sistema_template),
+ name=f"questor_{codigo_empresa_regra}_{competencia:%Y-%m}.csv",
+ )
+
+ importacao = ImportacaoPlanoSaudeDePaula.objects.create(
+ operadora=operadora_key,
+ nome_operadora=planos_saude_pipeline.label_operadora(operadora_key),
+ tipos_lancamento=tipos,
+ custeio_por_tipo=custeio_por_tipo,
+ regra_empresa=dados["regra_empresa"],
+ regra_custeio_salva=regra_custeio_obj,
+ criado_por=request.user,
+ competencia=competencia,
+ planilha_padrao=planilha_padrao_arquivo or planilha_padrao_gerada,
+ )
+ arquivos_operadora = [
+ ImportacaoPlanoSaudeDePaulaArquivoOperadora.objects.create(
+ importacao=importacao, arquivo=arquivo, ordem=ordem
+ )
+ for ordem, arquivo in enumerate(dados["arquivo_operadora"])
+ ]
+
+ def _limpa_arquivos_orfaos() -> None:
+ for arquivo in arquivos_operadora:
+ arquivo.arquivo.delete(save=False)
+ importacao.planilha_padrao.delete(save=False)
+ importacao.delete()
+
+ try:
+ if planilha_padrao_arquivo:
+ linhas_sistema_template = le_planilha_padrao(importacao.planilha_padrao.path)
+ vinculos_por_nome = _carrega_vinculos_por_nome_de_paula(operadora_key, linhas_sistema_template)
+ resultado = planos_saude_pipeline.processa_importacao(
+ operadora_key=operadora_key,
+ caminhos_arquivo_operadora=[arquivo.arquivo.path for arquivo in arquivos_operadora],
+ linhas_sistema_template=linhas_sistema_template,
+ tipos_selecionados=tipos,
+ custeio_por_tipo=custeio_por_tipo,
+ regra_empresa_key=dados["regra_empresa"] or None,
+ vinculos_por_nome=vinculos_por_nome,
+ )
+ except RegraEmpresaIncompativelError as exc:
+ _limpa_arquivos_orfaos()
+ return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
+ except Exception:
+ _limpa_arquivos_orfaos()
+ return Response(
+ {
+ "detail": (
+ "O formato de um dos arquivos não está conforme o esperado. "
+ "Contate a Inovação."
+ )
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ if planilha_padrao_arquivo and regra_custeio_obj and regra_custeio_obj.codigo_empresa:
+ todas_linhas = [linha for linhas in resultado.linhas_por_tipo.values() for linha in linhas]
+ codigo_esperado = normalizar_codigo_empresa(regra_custeio_obj.codigo_empresa)
+ if not any(normalizar_codigo_empresa(linha.codigo_empresa) == codigo_esperado for linha in todas_linhas):
+ _limpa_arquivos_orfaos()
+ return Response(
+ {
+ "detail": (
+ f'A regra "{regra_custeio_obj.nome}" foi cadastrada para a empresa código '
+ f"{regra_custeio_obj.codigo_empresa}, mas a planilha padrão anexada não tem "
+ "nenhuma linha com esse código."
+ )
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ linhas_specs = [
+ (tipo, ordem, linha)
+ for tipo, linhas in resultado.linhas_por_tipo.items()
+ for ordem, linha in enumerate(linhas)
+ ]
+ linhas_bulk = ImportacaoPlanoSaudeDePaulaLinha.objects.bulk_create([
+ ImportacaoPlanoSaudeDePaulaLinha(
+ importacao=importacao,
+ tipo_lancamento=tipo,
+ ordem=ordem,
+ codigo_empresa=linha.codigo_empresa,
+ nome_func=linha.nome_func,
+ cpf_func=linha.cpf_func,
+ codigo_out_emp=linha.codigo_out_emp,
+ data_inicial=linha.data_inicial,
+ nome_dependente=linha.nome_dependente,
+ cpf_dependente=linha.cpf_dependente,
+ valor_empresa=linha.valor_empresa,
+ valor=linha.valor,
+ descricao=linha.descricao,
+ )
+ for tipo, ordem, linha in linhas_specs
+ ])
+
+ linha_por_tipo_ordem = {
+ (tipo, ordem): linha_obj for (tipo, ordem, _), linha_obj in zip(linhas_specs, linhas_bulk)
+ }
+ alteracoes_vinculo_bulk = []
+ for vinculo_aplicado in resultado.vinculos_aplicados:
+ linha_obj = linha_por_tipo_ordem.get((vinculo_aplicado.tipo_lancamento, vinculo_aplicado.indice_linha))
+ if linha_obj is None:
+ continue
+ alteracoes_vinculo_bulk.append(ImportacaoPlanoSaudeDePaulaAlteracao(
+ importacao=importacao,
+ tipo=ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_VINCULO_AUTOMATICO,
+ linha=linha_obj,
+ tipo_lancamento=vinculo_aplicado.tipo_lancamento,
+ valor_novo=vinculo_aplicado.nome_arquivo_operadora,
+ dados_linha=_snapshot_linha_plano_saude(linha_obj),
+ vinculo_nome_id=vinculo_aplicado.vinculo_id,
+ usuario=request.user,
+ ))
+ ImportacaoPlanoSaudeDePaulaAlteracao.objects.bulk_create(alteracoes_vinculo_bulk)
+
+ auditoria_bulk = [
+ ImportacaoPlanoSaudeDePaulaAuditoria(
+ importacao=importacao,
+ motivo=item.motivo,
+ tipo_lancamento=item.tipo_lancamento,
+ numero_beneficiario=item.numero_beneficiario,
+ nome=item.nome,
+ cpf=item.cpf,
+ tipo=item.tipo,
+ valor=item.valor,
+ detalhe=item.detalhe,
+ )
+ for item in resultado.auditoria
+ ]
+ ImportacaoPlanoSaudeDePaulaAuditoria.objects.bulk_create(auditoria_bulk)
+
+ serializer = ImportacaoPlanoSaudeDePaulaDetailSerializer(importacao)
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+ @action(detail=True, methods=["post"])
+ def gerar(self, request: Request, pk: str | None = None) -> HttpResponse:
+ importacao = self.get_object()
+ linhas_por_tipo: dict[str, list[ImportacaoPlanoSaudeDePaulaLinha]] = {}
+ for linha in importacao.linhas.order_by("tipo_lancamento", "ordem", "id"):
+ linhas_por_tipo.setdefault(linha.tipo_lancamento, []).append(linha)
+
+ arquivos = {tipo: _monta_csv_linhas_plano_saude(linhas) for tipo, linhas in linhas_por_tipo.items()}
+
+ importacao.status = ImportacaoPlanoSaudeDePaula.STATUS_CONCLUIDA
+ importacao.concluida_em = timezone.now()
+ importacao.save(update_fields=["status", "concluida_em"])
+
+ if len(arquivos) == 1:
+ (tipo, conteudo), = arquivos.items()
+ response = HttpResponse(conteudo, content_type="text/csv; charset=utf-8-sig")
+ response["Content-Disposition"] = f'attachment; filename="{tipo}.csv"'
+ return response
+
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w") as zf:
+ for tipo, conteudo in arquivos.items():
+ zf.writestr(f"{tipo}.csv", conteudo)
+ response = HttpResponse(buffer.getvalue(), content_type="application/zip")
+ response["Content-Disposition"] = f'attachment; filename="importacao_plano_saude_de_paula_{importacao.id}.zip"'
+ return response
+
+ @action(detail=True, methods=["post"])
+ def reabrir(self, request: Request, pk: str | None = None) -> Response:
+ importacao = self.get_object()
+ importacao.status = ImportacaoPlanoSaudeDePaula.STATUS_REVISAO
+ importacao.concluida_em = None
+ importacao.save(update_fields=["status", "concluida_em"])
+ serializer = ImportacaoPlanoSaudeDePaulaDetailSerializer(importacao)
+ return Response(serializer.data)
+
+
+class ImportacaoPlanoSaudeDePaulaLinhaViewSet(viewsets.ModelViewSet):
+ """Ver `ImportacaoPlanoSaudeLinhaViewSet` — mesmo fluxo, escopo De Paula."""
+
+ http_method_names = ["get", "post", "patch", "delete", "head", "options"]
+ queryset = ImportacaoPlanoSaudeDePaulaLinha.objects.all()
+
+ def get_permissions(self) -> list[BasePermission]:
+ return [PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")]
+
+ def get_serializer_class(self) -> type[ModelSerializer]:
+ if self.action == "create":
+ return ImportacaoPlanoSaudeDePaulaLinhaCreateSerializer
+ return ImportacaoPlanoSaudeDePaulaLinhaSerializer
+
+ def perform_create(self, serializer: ImportacaoPlanoSaudeDePaulaLinhaCreateSerializer) -> None:
+ importacao = serializer.validated_data["importacao"]
+ _garante_importacao_de_paula_em_revisao(importacao)
+ tipo = serializer.validated_data["tipo_lancamento"]
+ maior_ordem = ImportacaoPlanoSaudeDePaulaLinha.objects.filter(
+ importacao=importacao, tipo_lancamento=tipo
+ ).aggregate(Max("ordem"))["ordem__max"]
+ linha = serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1)
+ ImportacaoPlanoSaudeDePaulaAlteracao.objects.create(
+ importacao=importacao,
+ tipo=ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_INCLUSAO,
+ linha=linha,
+ tipo_lancamento=tipo,
+ dados_linha=_snapshot_linha_plano_saude(linha),
+ usuario=self.request.user,
+ )
+
+ def perform_update(self, serializer: ImportacaoPlanoSaudeDePaulaLinhaSerializer) -> None:
+ linha_anterior = serializer.instance
+ _garante_importacao_de_paula_em_revisao(linha_anterior.importacao)
+ valores_anteriores = {
+ campo: getattr(linha_anterior, campo)
+ for campo in PLANO_SAUDE_CAMPOS_ALTERACAO
+ if campo in serializer.validated_data and serializer.validated_data[campo] != getattr(linha_anterior, campo)
+ }
+ linha = serializer.save()
+ for campo, valor_anterior in valores_anteriores.items():
+ ImportacaoPlanoSaudeDePaulaAlteracao.objects.create(
+ importacao=linha.importacao,
+ tipo=ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_EDICAO,
+ linha=linha,
+ tipo_lancamento=linha.tipo_lancamento,
+ campo=campo,
+ valor_anterior=valor_anterior,
+ valor_novo=getattr(linha, campo),
+ dados_linha=_snapshot_linha_plano_saude(linha),
+ usuario=self.request.user,
+ )
+
+ def perform_destroy(self, instance: ImportacaoPlanoSaudeDePaulaLinha) -> None:
+ _garante_importacao_de_paula_em_revisao(instance.importacao)
+ ImportacaoPlanoSaudeDePaulaAlteracao.objects.create(
+ importacao=instance.importacao,
+ tipo=ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_EXCLUSAO,
+ tipo_lancamento=instance.tipo_lancamento,
+ dados_linha=_snapshot_linha_plano_saude(instance),
+ usuario=self.request.user,
+ )
+ instance.delete()
+
+
+class ImportacaoPlanoSaudeDePaulaAlteracaoViewSet(viewsets.GenericViewSet):
+ """Ver `ImportacaoPlanoSaudeAlteracaoViewSet` — mesma reversão, escopo De Paula."""
+
+ http_method_names = ["post", "head", "options"]
+ queryset = ImportacaoPlanoSaudeDePaulaAlteracao.objects.all()
+ serializer_class = ImportacaoPlanoSaudeDePaulaAlteracaoSerializer
+
+ def get_permissions(self) -> list[BasePermission]:
+ return [PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")]
+
+ @action(detail=True, methods=["post"])
+ def reverter(self, request: Request, pk: str | None = None) -> Response:
+ alteracao = self.get_object()
+ _garante_importacao_de_paula_em_revisao(alteracao.importacao)
+ if alteracao.revertida:
+ raise ValidationError({"detail": "Esta alteração já foi revertida."})
+
+ if alteracao.tipo == ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_EDICAO:
+ if not alteracao.linha_id:
+ raise ValidationError({"detail": "A linha desta alteração não existe mais."})
+ setattr(alteracao.linha, alteracao.campo, alteracao.valor_anterior)
+ alteracao.linha.save(update_fields=[alteracao.campo])
+ elif alteracao.tipo == ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_INCLUSAO:
+ if not alteracao.linha_id:
+ raise ValidationError({"detail": "Esta linha já não existe mais."})
+ alteracao.linha.delete()
+ alteracao.linha = None
+ elif alteracao.tipo == ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_EXCLUSAO:
+ dados = dict(alteracao.dados_linha or {})
+ alteracao.linha = ImportacaoPlanoSaudeDePaulaLinha.objects.create(
+ importacao=alteracao.importacao,
+ tipo_lancamento=alteracao.tipo_lancamento,
+ **dados,
+ )
+ elif alteracao.tipo == ImportacaoPlanoSaudeDePaulaAlteracao.TIPO_VINCULO_AUTOMATICO:
+ if not alteracao.linha_id:
+ raise ValidationError({"detail": "A linha desta alteração não existe mais."})
+ alteracao.linha.valor_empresa = "0"
+ alteracao.linha.valor = "0"
+ alteracao.linha.save(update_fields=["valor_empresa", "valor"])
+ if alteracao.tipo_lancamento == "mensalidade" and alteracao.importacao.regra_empresa:
+ _recalcula_familia_regra_empresa_de_paula(alteracao.importacao, alteracao.linha)
+ if alteracao.vinculo_nome_id:
+ alteracao.vinculo_nome.delete()
+ alteracao.vinculo_nome = None
+
+ alteracao.revertida = True
+ alteracao.revertida_em = timezone.now()
+ alteracao.save()
+ return Response(ImportacaoPlanoSaudeDePaulaAlteracaoSerializer(alteracao).data)
+
+
+def _recalcula_familia_regra_empresa_de_paula(
+ importacao: ImportacaoPlanoSaudeDePaula, linha: ImportacaoPlanoSaudeDePaulaLinha
+) -> None:
+ """Ver `_recalcula_familia_regra_empresa` — mesma redistribuição por
+ família, escopo De Paula (`bulk_update` precisa do model certo)."""
+ regra = planos_saude_regras_empresa.REGRAS_EMPRESA.get(importacao.regra_empresa)
+ if not regra:
+ return
+ linhas_familia = list(
+ importacao.linhas.filter(
+ tipo_lancamento=linha.tipo_lancamento, nome_func=linha.nome_func
+ ).order_by("ordem", "id")
+ )
+ linhas_e_valores = [(l, parse_valor_br(l.valor_empresa) + parse_valor_br(l.valor)) for l in linhas_familia]
+ regra["aplica"](linhas_e_valores, linha.tipo_lancamento)
+ ImportacaoPlanoSaudeDePaulaLinha.objects.bulk_update(linhas_familia, ["valor_empresa", "valor"])
+
+
+class ImportacaoPlanoSaudeDePaulaAuditoriaViewSet(viewsets.GenericViewSet):
+ """Ver `ImportacaoPlanoSaudeAuditoriaViewSet` — mesma resolução manual,
+ escopo De Paula (grava em `VinculoNomeOperadoraDePaula`, nunca no DE/PARA
+ da aplicação original)."""
+
+ http_method_names = ["post", "head", "options"]
+ queryset = ImportacaoPlanoSaudeDePaulaAuditoria.objects.all()
+ serializer_class = ImportacaoPlanoSaudeDePaulaAuditoriaSerializer
+
+ def get_permissions(self) -> list[BasePermission]:
+ return [PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")]
+
+ @action(detail=True, methods=["post"])
+ def resolver(self, request: Request, pk: str | None = None) -> Response:
+ item = self.get_object()
+ _garante_importacao_de_paula_em_revisao(item.importacao)
+
+ if item.motivo not in ImportacaoPlanoSaudeDePaulaAuditoria.MOTIVOS_RESOLVIVEIS:
+ raise ValidationError({"detail": "Esse item de auditoria não pode ser resolvido manualmente."})
+ if item.resolvida:
+ raise ValidationError({"detail": "Este item já foi resolvido."})
+
+ linha_id = request.data.get("linha_id")
+ if not linha_id:
+ raise ValidationError({"linha_id": "Informe a linha da planilha padrão correspondente."})
+ linha = get_object_or_404(
+ ImportacaoPlanoSaudeDePaulaLinha, pk=linha_id, importacao_id=item.importacao_id
+ )
+
+ if linha.tipo_lancamento != item.tipo_lancamento:
+ raise ValidationError({"linha_id": "A linha escolhida não é do mesmo tipo de lançamento deste item."})
+
+ linha_eh_titular = not linha.nome_dependente.strip() and not linha.cpf_dependente.strip()
+ if linha_eh_titular != (item.tipo == "T"):
+ raise ValidationError(
+ {"linha_id": "A linha escolhida não corresponde ao mesmo tipo de beneficiário (titular/dependente) deste item."}
+ )
+ if linha.valor != "0" or linha.valor_empresa != "0":
+ raise ValidationError({"linha_id": "Essa linha já tem um valor lançado — escolha uma linha ainda em branco."})
+
+ regra_empresa_dados = (
+ planos_saude_regras_empresa.REGRAS_EMPRESA.get(item.importacao.regra_empresa)
+ if item.importacao.regra_empresa
+ else None
+ )
+ regra_empresa_cobre_este_tipo = bool(
+ regra_empresa_dados and item.tipo_lancamento in regra_empresa_dados.get("tipos_lancamento", ("mensalidade",))
+ )
+ if regra_empresa_cobre_este_tipo:
+ linha.valor_empresa = "0"
+ linha.valor = formata_valor_br(float(item.valor))
+ linha.save(update_fields=["valor_empresa", "valor"])
+ _recalcula_familia_regra_empresa_de_paula(item.importacao, linha)
+ else:
+ regra_por_pessoa = (item.importacao.custeio_por_tipo or {}).get(item.tipo_lancamento, {})
+ linha.valor_empresa, linha.valor = planos_saude_matcher.valores_formatados_para_pessoa(
+ float(item.valor), regra_por_pessoa, item.tipo
+ )
+ linha.save(update_fields=["valor_empresa", "valor"])
+
+ item.resolvida = True
+ item.linha_vinculada = linha
+ item.save(update_fields=["resolvida", "linha_vinculada"])
+
+ codigo_empresa_norm = normalizar_codigo_empresa(linha.codigo_empresa)
+ if codigo_empresa_norm:
+ destino = {"nome_func_destino": "", "nome_dependente_destino": ""}
+ if item.tipo == "T":
+ destino["nome_func_destino"] = linha.nome_func
+ else:
+ destino["nome_dependente_destino"] = linha.nome_dependente
+ VinculoNomeOperadoraDePaula.objects.update_or_create(
+ operadora=item.importacao.operadora,
+ codigo_empresa=codigo_empresa_norm,
+ nome_arquivo_operadora=normaliza_nome(item.nome),
+ defaults={**destino, "criado_por": request.user},
+ )
+
+ return Response(ImportacaoPlanoSaudeDePaulaAuditoriaSerializer(item).data)
+
+
+class RegraCusteioPlanoSaudeDePaulaViewSet(viewsets.ModelViewSet):
+ """Ver `RegraCusteioPlanoSaudeViewSet` — mesmo cadastro, escopo De Paula."""
+
+ http_method_names = ["get", "post", "patch", "delete", "head", "options"]
+ queryset = RegraCusteioPlanoSaudeDePaula.objects.all()
+ serializer_class = RegraCusteioPlanoSaudeDePaulaSerializer
+
+ def get_permissions(self) -> list[BasePermission]:
+ return [PermissaoApp("utilitarios", "importacao-plano-saude-de-paula")]
+
+ def perform_create(self, serializer: RegraCusteioPlanoSaudeDePaulaSerializer) -> None:
+ serializer.save(criado_por=self.request.user)
+
+ @action(detail=False, methods=["get"], url_path="nome-empresa")
+ def nome_empresa(self, request: Request) -> Response:
+ codigo = normalizar_codigo_empresa(request.query_params.get("codigo_empresa") or "")
+ if not codigo:
+ return Response({"detail": "Informe codigo_empresa."}, status=status.HTTP_400_BAD_REQUEST)
+ return Response({"codigo_empresa": codigo, "nome_empresa": resolve_nome_empresa(codigo)})
+
+
class IndicadorPercentualTipoViewSet(viewsets.ModelViewSet):
"""Cadastro dos percentuais individual/grupo/departamento por tipo de
colaborador (ver IndicadorPercentualTipo em models.py) — nunca editado
diff --git a/static/js/importacao-plano-saude.js b/static/js/importacao-plano-saude.js
index 026c33e..ec89252 100644
--- a/static/js/importacao-plano-saude.js
+++ b/static/js/importacao-plano-saude.js
@@ -1,3 +1,21 @@
+// Injetado via
+
+
+
+
+
+
+
+
+ Importação de Plano de Saúde - De Paula
+
+
+
+
+
+
+
+
Sem acesso a Importação de Plano de Saúde - De Paula
+
Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Inovação a liberação em Perfis de Acesso.
+
+
+
+
+
+
+
+
+ Excluir selecionadas
+
+
+
+
Nenhuma importação realizada ainda.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Vincular pessoa
+
+
+ Buscar na planilha padrão
+
+
+
+
+
+ Cancelar
+ Vincular
+
+
+
+
+
+
+
Selecionar regra empresa
+
Regras especiais cadastradas diretamente pela Inovação — cada uma substitui o custeio manual só do(s) tipo(s) de lançamento que ela cobre (mensalidade e/ou coparticipação, varia por regra); os demais tipos selecionados continuam usando o custeio configurado manualmente abaixo.
+
+
+
+ Fechar
+
+
+
+
+
+
+
Cadastro de Regras de Custeio
+
Cadastre e edite aqui as regras de custeio por empresa e operadora — a tela de Nova Importação só aplica uma regra já cadastrada, sem editar nada.
+
+
+
+
Selecione uma empresa e uma operadora pra visualizar/editar a regra, ou clique em "+ Nova regra".
+
+
+
+
+
+
+ Código da empresa
+
+
+
+
+
+
Operadora
+
+
Já existe uma regra cadastrada para esta empresa com esta operadora — edite a regra existente em vez de criar outra.
+
+
+
+
+
+
+
+
+ Excluir regra
+
+
+ Fechar
+ Cancelar
+ Salvar alterações
+ Criar regra
+
+
+
+
+
+
+
+
+
Gerenciar Usuário
+
+ Alterar senha
+
+
+
Usuários sob sua liderança
+
+
+
+
+ Fechar
+ Salvar liderados
+
+
+
+
+
+
+
Alterar senha
+
+
+
+
Confirmar nova senha
+
+
+
+
+ Cancelar
+ Salvar
+
+
+
+
+
+
+
Histórico de Notificações
+
+
+ Fechar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+