diff --git a/.claude/settings.json b/.claude/settings.json
index f26af79..8545763 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -150,7 +150,8 @@
"PowerShell($f = \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\portal_api\\\\dashboard_contabil\\\\CHANGELOG.md\"; \\(Get-Content $f | Measure-Object -Line\\).Lines)",
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" manage.py makemigrations portal_api --name contabil_ordering_determinista --dry-run -v 2)",
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" manage.py makemigrations portal_api --name contabil_ordering_determinista)",
- "PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\33b72ffc-a7b3-4b7e-8afe-bb65121e9a85\\\\scratchpad\\\\testa_removidos.py\")"
+ "PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\33b72ffc-a7b3-4b7e-8afe-bb65121e9a85\\\\scratchpad\\\\testa_removidos.py\")",
+ "PowerShell(.venv\\\\Scripts\\\\python.exe manage.py migrate portal_api 0080)"
],
"additionalDirectories": [
"C:\\Users\\Depaula\\AppData\\Local\\Temp\\claude\\c--Users-Depaula-Documents-Portal\\3ea0ee22-e5fd-4030-98b1-ee2e71c16ce0\\scratchpad\\halloween-design",
diff --git a/CLAUDE.md b/CLAUDE.md
index 73bd38d..4e028a3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -342,7 +342,7 @@ Ver `portal_api/nao_conformidades/CLAUDE.md`.
Chamado de "Dashboard Contábil" até uma rodada anterior — renomeado pra "Relatório Contábil" a pedido explícito do usuário, pra soar como um aliado do trabalho do contador em vez de mais um processo/sistema novo (rename só de rótulo visível: menu, título da página, cabeçalhos e o botão que gera o relatório; nomes técnicos internos — pasta do pacote, arquivos, classes de model, rotas — continuam `dashboard_contabil`/`dashboard-contabil.*`/`Contabil*`/`/contabil-*`, sem nenhuma mudança).
-Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (`ITD-FISCO-7513`): o contador anexa o PDF de Balancete + DRE (modelo Questor, mesmo relatório hoje enviado ao cliente), a ferramenta extrai as contas e roda um motor de regras de auditoria (saldos negativos, contas transitórias/genéricas com saldo, contas que deveriam ficar zeradas, débito ≠ crédito, variações atípicas mês a mês contra o histórico já processado no Portal), com revisão de achados e observações por conta antes da conclusão (a observação fica no histórico da empresa + conta e reaparece assinada nas competências seguintes, até ser encerrada) — cada conta/linha também ganha um checkbox de "validado" (marcador informativo de que o contador já conferiu aquele item) e um editor de observação inline na própria tabela (não mais um popup), com um toggle "mostrar ao cliente" que nasce desmarcado por padrão. O botão "Gerar Relatório" gera um relatório HTML autocontido (indicadores financeiros, gráfico de evolução, DRE/Balancete agrupados, observações do contador — com a marca do escritório, não a "P.I.D." do Portal), com exportação de Balancete/DRE em XLSX a partir dele. **Ainda fora de escopo**: consolidação entre várias empresas/competências ao mesmo tempo (substituiria o BI Contábil por completo).
+Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (`ITD-FISCO-7513`): o contador anexa o PDF de Balancete + DRE (modelo Questor ou Contabit, detectado sozinho, mesmo relatório hoje enviado ao cliente; no Contabit o código da empresa vem do nome do arquivo e a seção de indicadores fica oculta), a ferramenta extrai as contas e roda um motor de regras de auditoria (saldos negativos, contas transitórias/genéricas com saldo, contas que deveriam ficar zeradas, débito ≠ crédito, variações atípicas mês a mês contra o histórico já processado no Portal), com revisão de achados e observações por conta antes da conclusão (a observação fica no histórico da empresa + conta e reaparece assinada nas competências seguintes, até ser encerrada) — cada conta/linha também ganha um checkbox de "validado" (marcador informativo de que o contador já conferiu aquele item) e um editor de observação inline na própria tabela (não mais um popup), com um toggle "mostrar ao cliente" que nasce desmarcado por padrão. O botão "Gerar Relatório" gera um relatório HTML autocontido (indicadores financeiros, gráfico de evolução, DRE/Balancete agrupados, observações do contador — com a marca do escritório, não a "P.I.D." do Portal), com exportação de Balancete/DRE em XLSX a partir dele. **Ainda fora de escopo**: consolidação entre várias empresas/competências ao mesmo tempo (substituiria o BI Contábil por completo).
Ver `portal_api/dashboard_contabil/CLAUDE.md`.
diff --git a/portal_api/dashboard_contabil/CHANGELOG.md b/portal_api/dashboard_contabil/CHANGELOG.md
index ebd5fa2..0cccfbf 100644
--- a/portal_api/dashboard_contabil/CHANGELOG.md
+++ b/portal_api/dashboard_contabil/CHANGELOG.md
@@ -462,3 +462,37 @@ Validado com `manage.py check` + `makemigrations --check` (estado de migração
Limite conhecido e aceito: o aviso vale para o reprocessamento em que a remoção aconteceu. Como todo achado é recriado do zero e a linha já não está no banco, reprocessar de novo com o mesmo arquivo não repete o aviso — mesmo espírito de `alterada_reprocessamento`, que também marca a mudança daquela rodada.
Mexe em `.py`: exige reiniciar o `runserver`. E exige `python manage.py migrate` antes de usar.
+
+### Rodada 152 — Segundo modelo de arquivo: Balancete + DRE do Contabit
+
+Pedido: além do Questor, parte dos clientes recebe o Balancete + DRE gerado pelo Contabit, e a aplicação precisa funcionar com esse modelo também. Arquivo de referência: `1512 - Balancete 082026.pdf` (em `Projetos\Balancetes\Contabit`, fora do repositório).
+
+Decisões do usuário, confirmadas antes de implementar: (1) o código da empresa, que o PDF do Contabit não traz, vem do prefixo numérico do nome do arquivo; o Questor continua lendo o código do PDF; (2) valores exibidos como no PDF (saldos pela natureza da conta, Passivo positivo), sem converter para a convenção do Questor; (3) empresas do Contabit não usam indicadores por enquanto, e a seção fica oculta; (4) estruturar com base neste único arquivo e ir testando conforme chegarem outros.
+
+- **Detecção automática**, sem escolha do usuário: `parser.extrai_balancete_dre()` olha a primeira página ("Classificação/Conta" + "PERÍODO DE ... À ...") e despacha para o módulo novo `parser_contabit.py`. Conversões e erros compartilhados foram para `conversao.py` (só para evitar import circular; `parser.py` reexporta `ExtracaoInvalidaError`).
+- **Leitura do Balancete pela ordem do fluxo do PDF**, não por posição: descrição longa é cortada visualmente mas continua no texto, e seus caracteres ficam na mesma faixa do nº da conta (ordenar por `x0` produzia "GRAFICOS2 2L9T3D5A" para "GRAFICOS LTDA" + conta "22935"). Sintética = linha sem nº de conta (não existe flag S/A). `ContabilConta.conta_numero` passou a aceitar nulo.
+- DRE e demonstração mensal agrupam caracteres com tolerância vertical (o valor das linhas em negrito vem 1pt abaixo do texto), descartam a coluna TOTAL e as linhas espaçadoras só com zero, e convertem "JUNHO/2026" para "jun/2026".
+- **Regras**: balanceamento compara Ativo = Passivo (os dois positivos); caixa negativo usa `1.10.10.01`; lucro Balancete x DRE usa `2.40.40.20` sem negar; sinal invertido vira "analítica com saldo negativo" (premissa, sem exemplo real ainda: o Contabit imprimir o saldo contrário com "-"; se não imprimir, a regra só não dispara). As outras cinco não mudaram.
+- **Campo novo `ContabilApuracao.leiaute`** (migração `0080`, `db_default="questor"`, porque o banco é o de produção e um processo com o código anterior não enviaria a coluna). Propriedade `tem_indicadores` esconde a seção de indicadores na aba "Dashboard" (botão "Gerenciar Indicadores" incluso), no relatório e no PDF do Resumo.
+- Nome de arquivo sem código devolve 400 com mensagem própria (`CodigoEmpresaAusenteError`). A mensagem de reprocessar com empresa diferente passou a citar o código, que no Contabit é a única diferença visível.
+
+Validado: extração do PDF do Contabit (130 contas, todas fechando saldo anterior + débito − crédito = saldo atual pela natureza; Ativo = Passivo; débito = crédito; lucro do Balancete = DRE), simulações de saldo contrário, regressão contra os 15 PDFs Questor disponíveis (extração e apontamentos idênticos ao código anterior) e fluxo completo pela API com rollback (criar, abrir, Dashboard, relatório, PDF do Resumo, XLSX, reprocessar), com o arquivo gravado pelo teste apagado depois.
+
+Mexe em `.py`: exige reiniciar o `runserver`. A migração `0080` já foi aplicada.
+
+### Rodada 153 — Variações atípicas no fim da aba Análise Vertical, com botão de validar
+
+Pedido do usuário, com print da aba Análise Vertical: (1) os apontamentos de variação passam a se chamar "Variação atípica na DRE - Análise Vertical"; (2) as variações ficam listadas no fim da página da Análise Vertical, cada uma com um botão para confirmar que foi validada. Perguntado o que "validar" faz: trata o apontamento, e os textos funcionam como as observações das contas, com a opção de mostrar ao cliente.
+
+- Título novo em `regras.TITULO_VARIACAO_ATIPICA_DRE` e no rótulo da regra no JS; migração de dados `0081` renomeou os apontamentos já gravados (só `titulo`, a `mensagem` fica como está).
+- Action nova `POST /api/contabil-achados/{id}/validar/` (`ContabilAchadoValidarSerializer`), só para `variacao_atipica_dre`: trata sem exigir `observacao_contador`, ou volta a pendente. As outras regras seguem com justificativa obrigatória.
+- Seção nova no fim da aba (`renderVariacoesAnaliseVertical()`): card por variação com "Ver na tabela", a thread de observação da linha (a mesma da tabela, via `dcObsPainelConteudoHtml()` com escopo `variacao`) e o botão Validar/Validado. Na aba Observações, esses apontamentos trocaram "Revisar" pelo mesmo botão.
+
+Validado: `manage.py check`, `makemigrations --check`, endpoint testado com rollback (validar, desfazer, 400 em outra regra e em apuração concluída). O JS não pôde ser executado neste ambiente (sem Node nem navegador): conferir na tela.
+
+Mexe em `.py`: exige reiniciar o `runserver`. A migração `0081` já foi aplicada.
+
+**Ajustes na mesma rodada, depois do teste do usuário**:
+- O primeiro teste deu 404 no validar. Não era bug: o `runserver` tinha carregado o `views.py` 5 segundos antes da gravação da action nova e o recarregamento automático se perdeu (4 processos `runserver` encadeados). Resolvido reiniciando o servidor.
+- As variações saíram da aba Observações (lista, donut e card "Variações e Indicadores"), já que têm lista própria no fim da aba Análise Vertical (`dcAchadosDaAbaObservacoes()`). Com isso o botão Validar que tinha sido posto nos cards daquela aba também saiu.
+- Linha da Análise Vertical com variação atípica ganhou um ícone antes do botão de validado (dourado pendente, teal validada). Hover mostra a variação e as observações da linha; clique leva ao card da variação com a thread aberta, para validar e comentar.
diff --git a/portal_api/dashboard_contabil/CLAUDE.md b/portal_api/dashboard_contabil/CLAUDE.md
index c92f199..1b77a19 100644
--- a/portal_api/dashboard_contabil/CLAUDE.md
+++ b/portal_api/dashboard_contabil/CLAUDE.md
@@ -6,12 +6,13 @@
**Nome**: "Relatório Contábil" é o rótulo visível ao usuário; internamente tudo continua `dashboard_contabil`/`dashboard-contabil.*`/`Contabil*`/`/api/contabil-*`/`apps["dashboard-contabil"]`. O rename foi só de texto visível (menu em `catalogo.py`, `
`/`
`/cabeçalhos dos dois templates, o botão "Gerar Relatório" e os `verbose_name` do admin) — pedido explícito do usuário, para a ferramenta soar como um aliado do trabalho do contador em vez de mais um sistema. **Não propagar esse rename para dentro do código.**
-Ferramenta que otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (processo descrito em `ITD-FISCO-7513`, "Roteiro de Conferência de Balancete"): o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor já enviado ao cliente), a ferramenta extrai as contas/linhas, roda um motor de regras de auditoria e apresenta os apontamentos numa tela de revisão, onde o contador analisa, registra observações e conclui a análise. No fim, gera um relatório HTML autocontido para o cliente.
+Ferramenta que otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (processo descrito em `ITD-FISCO-7513`, "Roteiro de Conferência de Balancete"): o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório já enviado ao cliente, gerado pelo **Questor** ou pelo **Contabit**, ver "Leiaute Contabit" abaixo), a ferramenta extrai as contas/linhas, roda um motor de regras de auditoria e apresenta os apontamentos numa tela de revisão, onde o contador analisa, registra observações e conclui a análise. No fim, gera um relatório HTML autocontido para o cliente.
**Permissão**: toggle único `apps["dashboard-contabil"]` em `permissoes["relatorios"]` (subgrupo "Contabilidade"), checado via `PermissaoApp("relatorios", "dashboard-contabil")` em todos os ViewSets. **Nasce restrita ao perfil "Inovação"** (override em `seed_portal.py`), já que expõe balancete/DRE completos dos clientes.
## Decisões de escopo (confirmadas com o usuário)
+- **Dois modelos de PDF, detectados sozinhos**: Questor e Contabit. O usuário nunca escolhe; `ContabilApuracao.leiaute` guarda o detectado. Ver "Leiaute Contabit" abaixo para o que muda.
- **Entrada: só PDF.** O Questor também exporta Balancete/DRE em XLSX estruturado (mais confiável de extrair), levantado como alternativa e recusado pelo usuário. Não há suporte a XLSX na entrada.
- **Sempre uma apuração por vez.** Sem consolidação entre empresas ou competências — isso seria um BI à parte. O BI Contábil externo que esta ferramenta substitui tem filtros "Ano-Mês"/"Empresa-Filial" que sugerem o contrário; o escopo confirmado é uma apuração por vez.
- **O histórico para comparação mês a mês fica no próprio Portal.** Cada apuração processada fica salva (`ContabilApuracao`, chave natural `codigo_empresa`+`competencia`), e é contra ela que se compara. Não depende do relatório "Análise Comparativa Mensal"/"Acompanhamento Mensal", que é um relatório complementar enviado ao cliente por fora, não uma entrada desta ferramenta.
@@ -44,6 +45,26 @@ A solução adotada é **avisar, não corrigir**: `ContabilApuracao.fonte_pdf_at
Validado contra os 3 PDFs reais disponíveis: `True` só para o `1751`, `False` para `792` e `2017` (sem falso positivo nos já confirmados corretos).
+## Leiaute Contabit (`parser_contabit.py`)
+
+`extrai_balancete_dre(origem, nome_arquivo)` abre o PDF e, se `parser_contabit.eh_leiaute_contabit(primeira página)` (cabeçalho com "Classificação/Conta" e "PERÍODO DE ... À ..."), devolve `parser_contabit.extrai(pdf, nome_arquivo)`; senão segue o caminho do Questor, que não mudou. As conversões (`para_decimal`/`para_percentual`/`para_data`) e os erros vivem em `conversao.py` só para evitar import circular; `parser.py` reexporta `ExtracaoInvalidaError`/`CodigoEmpresaAusenteError`. Calibrado contra um único arquivo real, `1512 - Balancete 082026.pdf` (em `Projetos\Balancetes\Contabit`, fora do repositório). **Decisão do usuário: estruturar com base neste documento e ir testando conforme chegarem outros**, então tratar os pontos marcados como premissa com a devida desconfiança.
+
+**Código da empresa vem do nome do arquivo** (decisão explícita do usuário): o PDF só traz nome e CNPJ. `codigo_empresa_do_nome_arquivo()` pega o prefixo numérico ("1512 - Balancete 082026.pdf" → `"1512"`, sem padding). Sem prefixo, `CodigoEmpresaAusenteError` (subclasse de `ExtracaoInvalidaError`), que a view devolve como 400 com mensagem própria (`_contabil_resposta_extracao_invalida()`). `create()`/`reprocessar()` passam `arquivo.name` ao pipeline; o Questor ignora o parâmetro.
+
+**Balancete lido na ordem do fluxo do PDF, não por posição.** A descrição longa é cortada na tela (clip), mas o texto inteiro continua no PDF e seus caracteres ocupam a mesma faixa horizontal do nº da conta e até do Saldo Anterior: ordenar por `x0` produz "GRAFICOS2 2L9T3D5A" para "GRAFICOS LTDA" + conta "22935". `_trechos_por_fluxo()` segue `pagina.chars` na ordem em que foram desenhados (descrição, depois os valores, depois o nº da conta) e corta um trecho quando o próximo caractere recua mais que o kerning explica (`_RECUO_MAXIMO_KERNING`) ou salta mais que um espaço (`_SALTO_MAXIMO_TRECHO`). Trecho só monetário é valor (os 4 são ordenados entre si por `x0`: Saldo Anterior, Débito, Crédito, Saldo Atual), trecho só dígitos é o nº da conta, o resto é classificação + descrição. Qualquer linha com classificação e contagem de colunas diferente de 4 valores levanta erro em vez de gravar algo desalinhado.
+
+- **Sem flag S/A**: sintética é a linha sem nº de conta (`conta_numero=None`, por isso `ContabilConta.conta_numero` aceita nulo e a tela mostra vazio).
+- **Saldos pela natureza da conta**: Passivo e redutoras (depreciação acumulada, encargos a transcorrer) aparecem positivos, e o grupo pai faz a subtração. **Exibidos como no PDF** (decisão do usuário), sem converter para a convenção do Questor. Todas as 130 contas do arquivo de referência fecham `saldo anterior ± débito ∓ crédito = saldo atual` pela natureza.
+- Os espaços entre palavras são caracteres reais neste PDF (não a grade de fundo do Questor), então `_texto_por_posicao()` os mantém e só colapsa repetições.
+
+**DRE ("DEMONSTRAÇÃO DO RESULTADO ACUMULADO NO ANO")**: negativo com "-" (não parênteses). O valor das linhas em negrito é desenhado 1pt abaixo do texto, então `_agrupa_linhas()` junta caracteres com tolerância vertical (`_TOLERANCIA_LINHA`) em vez de `round(top)`. Nível pelo recuo, passo `_PASSO_NIVEL_DRE` (7,45pt). A última linha é "LUCRO LIQUIDO DO EXERCICIO".
+
+**Demonstração mensal ("DEMONSTRAÇÃO DO RESULTADO / MENSAL")** alimenta a mesma Análise Vertical do Questor: meses por extenso ("JUNHO/2026" → `"jun/2026"`, `_MESES_ABREVIADOS`), AV% sem "%", uma coluna **TOTAL** a mais (soma dos meses mostrados, descartada) e linhas espaçadoras só com zeros, sem descrição (ignoradas). Passo de nível `_PASSO_NIVEL_MENSAL` (5,95pt). Os rótulos não são iguais aos da DRE acumulada ("RESULTADO BRUTO" x "LUCRO BRUTO") e linhas zeradas no trimestre são omitidas; nada no código depende das duas árvores serem idênticas.
+
+**Regras que mudam por leiaute** (todas as outras rodam igual): balanceamento compara Ativo = Passivo (sem somar); caixa negativo usa o prefixo `1.10.10.01` (`PREFIXO_CAIXA_CONTABIT`); lucro Balancete x DRE lê `2.40.40.20` (`CODIGO_LUCRO_PREJUIZO_EXERCICIO_CONTABIT`) sem negar; sinal invertido (`_saldo_sinal_invertido_contabit()`) é "analítica com saldo negativo", com a natureza (grupo, invertida por redutora/descendente de redutora) usada só no texto. **Premissa não confirmada**: o Contabit imprimir saldo contrário à natureza com "-"; se imprimir de outro jeito, a regra só deixa de disparar, sem falso positivo.
+
+**Sem indicadores** (decisão do usuário, por enquanto): os indicadores cadastrados usam códigos e rótulos do plano do Questor e sairiam errados. `ContabilApuracao.tem_indicadores` (`False` no Contabit) faz `_contabil_dados_resumo()` devolver `indicadores_grupos` vazio sem calcular, `indicadores()` devolver vazio, o relatório e o PDF do Resumo pularem a seção, e a aba "Dashboard" esconder os cards, a dica e o botão "Gerenciar Indicadores" (`renderDashboardTab()`). Para ligar indicadores no Contabit no futuro seria preciso mapear os códigos por leiaute nos componentes (`1.10` Circulante, `2.10` PC, `2.40` PL, `1.20.30` Imobilizado, `1.20.30.20` Depreciação; Estoques ainda sem exemplo).
+
## Motor de regras de auditoria (`regras.py`)
Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, list[SnapshotHistorico]) -> list[AchadoDetectado]`.
@@ -62,7 +83,7 @@ Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, li
6. **`conta_transitoria_com_saldo`** (média) — descrição contém "TRANSIT" com `saldo_atual != 0`, **exceto** a palavra isolada "TRANSITO" (`\bTRANSITO\b`, "dinheiro em trânsito", conceito diferente). A exclusão é por palavra isolada e não por um trecho positivo mais longo como "TRANSITOR" porque, no PDF `1751`, a fonte corrompe o acento de "TRANSITÓRIA" num caractere não recuperável — "TRANSITOR" nunca casaria com essa conta, que tem saldo real.
7. **`conta_deveria_zerar`** (média) — `TRECHOS_CONTA_DEVERIA_ZERAR`, lista curta e deliberadamente restrita: só "ADIANTAMENTOS DE SALÁRIOS", que o ITD confirma dever ficar zerada todo mês. **Não** inclui "Adiantamento de Férias"/"13º Salário", que legitimamente carregam saldo entre meses.
8. **`descricao_generica`** (baixa) — descrição exatamente `"DIVERSOS"` com saldo relevante (o ITD cita esse caso: "o contador deverá realocar estes lançamentos a conta pertinente").
-9. **`variacao_atipica_dre`** (baixa) — usa a seção "Demonstração Mensal (Análise Vertical)" do próprio PDF (`atual.linhas_analise_vertical`), não o histórico do Portal. Compara os **2 meses mais recentes** dessa tabela pelo `percentual` de cada linha sobre a Receita Operacional Bruta. Dispara quando o salto entre os 2 meses é de pelo menos `VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO` (**1 ponto percentual** — piso para não disparar em saltos percentualmente grandes só porque a base já era perto de zero) **e**, quando o percentual anterior não é zero, `VARIACAO_LIMIAR_PERCENTUAL` (**65%** de variação relativa). Roda mesmo na 1ª apuração de uma empresa, desde que o PDF traga a seção.
+9. **`variacao_atipica_dre`** (baixa) — usa a seção "Demonstração Mensal (Análise Vertical)" do próprio PDF (`atual.linhas_analise_vertical`), não o histórico do Portal. Compara os **2 meses mais recentes** dessa tabela pelo `percentual` de cada linha sobre a Receita Operacional Bruta. Dispara quando o salto entre os 2 meses é de pelo menos `VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO` (**1 ponto percentual** — piso para não disparar em saltos percentualmente grandes só porque a base já era perto de zero) **e**, quando o percentual anterior não é zero, `VARIACAO_LIMIAR_PERCENTUAL` (**65%** de variação relativa). Roda mesmo na 1ª apuração de uma empresa, desde que o PDF traga a seção. Título "Variação atípica na DRE - Análise Vertical" (`TITULO_VARIACAO_ATIPICA_DRE`, pedido do usuário; a migração `0081` renomeou os já gravados). **Tratada de um jeito próprio, sem justificativa escrita**: ver "Variações atípicas no fim da aba Análise Vertical" abaixo.
### O 10º apontamento não é uma regra: conta/linha removida no reprocessamento
@@ -96,7 +117,7 @@ Padrão cabeçalho → linhas de detalhe → apontamentos, mesma filosofia de `I
### Apuração e linhas
-- **`ContabilApuracao`** — `codigo_empresa`/`nome_empresa`/`cnpj`/`competencia` (extraídos do PDF, não informados no upload) + `periodo_inicio`/`periodo_fim` + `arquivo` + `status` (`revisao`/`concluida`). `unique_together` em `codigo_empresa`+`competencia`. Mais: `analise_vertical_meses` (`JSONField`, ex. `["mai/2026", "jun/2026", "jul/2026"]`, lista compartilhada por toda a apuração), `fonte_pdf_atipica`, `resumo_fechamento` (texto rico do contador), `indicadores_ocultos` e `indicadores_selecionados` (`JSONField`, listas de chave de indicador).
+- **`ContabilApuracao`** — `codigo_empresa`/`nome_empresa`/`cnpj`/`competencia` (extraídos do PDF, não informados no upload) + `periodo_inicio`/`periodo_fim` + `arquivo` + `status` (`revisao`/`concluida`). `unique_together` em `codigo_empresa`+`competencia`. Mais: `analise_vertical_meses` (`JSONField`, ex. `["mai/2026", "jun/2026", "jul/2026"]`, lista compartilhada por toda a apuração), `fonte_pdf_atipica`, `leiaute` (`questor`/`contabit`, detectado pelo parser; `db_default` no banco, não só `default` do Django, porque o banco é o de produção e um processo com código anterior ao campo não o enviaria) + a propriedade `tem_indicadores`, `resumo_fechamento` (texto rico do contador), `indicadores_ocultos` e `indicadores_selecionados` (`JSONField`, listas de chave de indicador).
- **`ContabilConta`** — uma linha do Balancete. `conta_numero` (numeração interna do Questor) e `codigo` (classificação) são coisas diferentes, as duas extraídas. `validado` (`BooleanField`, checkbox informativo de "já conferi", sem gate em nada), `alterada_reprocessamento` + `valor_anterior_reprocessamento`.
- **`ContabilLinhaDre`** — uma linha da DRE, sem código de classificação. Mesmos `validado`/`alterada_reprocessamento`/`valor_anterior_reprocessamento`.
- **`ContabilLinhaAnaliseVertical`** — mesma árvore/descrição/nível da DRE, mas `valores` (`JSONField`) guarda um `{"valor": "...", "percentual": "..."}` **por mês**, gravado como **texto, não float**, para não perder precisão; alinhado por posição com `ContabilApuracao.analise_vertical_meses`. `valores_anterior_reprocessamento` tem o mesmo formato (não existe um valor único aqui). Lista vazia quando o PDF não traz a seção — relatório antigo ou empresa sem essa seção habilitada no Questor; a aba correspondente some nesse caso.
@@ -105,7 +126,7 @@ As três têm os mesmos recursos por linha: observação (via `ContabilObservaca
### Apontamentos de auditoria
-- **`ContabilAchado`** — nasce automático em `create()`, muda de `status` (`pendente`/`tratado`/`ignorado`) via `ContabilAchadoViewSet`, sempre com `observacao_contador` obrigatória ao sair de pendente. **Um reprocessamento apaga e recria todos os achados do zero** (ver "Reprocessar" abaixo). Dois campos de alvo, mutuamente exclusivos e os dois opcionais: `conta` (FK para `ContabilConta`) e `linha_analise_vertical` (FK para `ContabilLinhaAnaliseVertical`). As duas regras gerais (balanceamento e débito/crédito) não têm alvo nenhum.
+- **`ContabilAchado`** — nasce automático em `create()`, muda de `status` (`pendente`/`tratado`/`ignorado`) via `ContabilAchadoViewSet`, sempre com `observacao_contador` obrigatória ao sair de pendente, **exceto** `variacao_atipica_dre`, validada sem texto pela action `validar()` (o texto dela é observação da linha, ver "Variações atípicas no fim da aba Análise Vertical"). **Um reprocessamento apaga e recria todos os achados do zero** (ver "Reprocessar" abaixo). Dois campos de alvo, mutuamente exclusivos e os dois opcionais: `conta` (FK para `ContabilConta`) e `linha_analise_vertical` (FK para `ContabilLinhaAnaliseVertical`). As duas regras gerais (balanceamento e débito/crédito) não têm alvo nenhum.
> **"Achado" nunca aparece em texto visível ao usuário** — pedido explícito. Na UI a aba se chama "Observações" e os textos falam em "observação"/"apontamento". `achado`/`ContabilAchado`/`achados_com_observacao` continuam normais como nome de model/variável/classe CSS. Ver `[[feedback_nunca_achado_em_texto_visivel]]` na memória.
@@ -254,6 +275,7 @@ Todos os endpoints usam `PermissaoApp("relatorios", "dashboard-contabil")`. Nenh
| `/api/contabil-observacoes/{id}/` | PATCH/DELETE | `texto` e/ou `mostrar_ao_cliente`, cada um com sua regra / exclusão restrita ao autor e à revisão |
| `/api/contabil-observacoes/{id}/encerrar/`, `/reativar/` | POST | recebem a apuração aberta no corpo, que define a competência de corte |
| `/api/contabil-achados/{id}/` | GET/PATCH | tratar/ignorar (exige `observacao_contador` não vazia) |
+| `/api/contabil-achados/{id}/validar/` | POST | `{validado: bool}`, **só `variacao_atipica_dre`** (400 nas outras regras): `true` trata sem `observacao_contador`, `false` volta a pendente; bloqueado em apuração concluída |
| `/api/contabil-achados/{id}/alternar-oculto/` | POST | esconder a observação do achado no relatório, **independente da tratativa** (um achado pode continuar pendente e ter a observação escondida) |
| `/api/contabil-indicadores-definicoes/`, `/{id}/` | GET/POST/PATCH/DELETE | CRUD de indicador (corpo sempre o indicador **inteiro**, inclusive em PATCH) |
@@ -344,7 +366,7 @@ Filtros por severidade, por status e por regra, combinados por E lógico. Como n
Ordenação sempre por severidade (`PID_DC_SEVERIDADE_ORDEM = {alta:0, media:1, baixa:2}`), preservando a ordem original dentro da mesma severidade (`Array.prototype.sort` é estável).
-**Resumo no topo** (`.dc-achados-resumo`): um donut em SVG puro com a contagem por severidade e o total no centro, mais uma grade de 3 cards por **grupo temático** (`PID_DC_GRUPOS`): "Divergências de Saldo" (regras 1-5), "Contas Atípicas" (6-8) e "Variações e Indicadores" (9). Cada card mostra o total do grupo e, por baixo, uma linha por regra (label + contagem); regra sem achado continua listada com `0`, só não clicável. `renderAchadosResumo()` conta sempre sobre `apuracaoAtual.achados` **completo**, nunca sobre a lista filtrada — é uma visão geral estável.
+**Resumo no topo** (`.dc-achados-resumo`): um donut em SVG puro com a contagem por severidade e o total no centro, mais uma grade de 3 cards por **grupo temático** (`PID_DC_GRUPOS`): "Divergências de Saldo" (regras 1-5) e "Contas Atípicas" (6-8). A regra 9 (variação atípica) não entra nesta aba, ver "Variações atípicas no fim da aba Análise Vertical". Cada card mostra o total do grupo e, por baixo, uma linha por regra (label + contagem); regra sem achado continua listada com `0`, só não clicável. `renderAchadosResumo()` conta sempre sobre `apuracaoAtual.achados` **completo**, nunca sobre a lista filtrada — é uma visão geral estável.
> Sem Chart.js aqui de propósito (essa dependência só existe no relatório estático). O donut usa a técnica clássica de `` — circunferência ≈ 100, então `stroke-dasharray`/`stroke-dashoffset` já funcionam em unidades de percentual sem `pathLength`. Cada segmento é um `` próprio, clicável individualmente porque `pointer-events` de SVG só considera pixel realmente pintado pelo `stroke`, sem hit-test manual por ângulo.
@@ -364,6 +386,16 @@ Donut, legenda e linhas de regra são clicáveis e filtram a lista abaixo, com `
> **Não existe "ir para a DRE"** porque nenhuma regra hoje referencia `ContabilLinhaDre` diretamente. Se uma regra nova precisar, o padrão se replica sem reprojetar nada: FK `linha_dre` + `ordem_linha_dre` em `AchadoDetectado` + uma entrada em `DC_IR_PARA_CONFIG` com `tab: "dre"`.
+### Variações atípicas no fim da aba Análise Vertical
+
+Pedido explícito do usuário: os apontamentos de `variacao_atipica_dre` aparecem numa seção própria no fim da aba Análise Vertical (`#dc-av-variacoes-list`, `renderVariacoesAnaliseVertical()`, chamada no fim de `renderAnaliseVertical()`), cada um com "Ver na tabela", o botão de observação da linha e um botão "Validar".
+
+- **Validar trata o apontamento** (`status="tratado"`, com quem/quando) pelo endpoint `validar/`, sem o modal "Revisar". Clicar em "Validado" volta a pendente (`dcValidarVariacaoBtnHtml()`/`dcAlternarValidacaoVariacao()`).
+- **Ícone na própria linha da tabela** (pedido do usuário, `dcVariacaoIconeHtml()`/`PID_DC_VARIACAO_ICONE`), antes do botão de validado: dourado enquanto pendente, teal depois de validada. O hover (`.dc-hover-tooltip--largo`) mostra título, mensagem, quem validou e até 3 observações da linha; o clique (`dcIrParaVariacao()`) abre a thread no card da variação, rola até ele e dá um pulso (`dcVariacaoFoco`, 2,4s). Validar redesenha a tabela inteira (`renderAnaliseVertical()`), não só a lista, para o ícone mudar de cor junto.
+- **Não aparecem na aba Observações** (pedido explícito do usuário, já estão aqui): `dcAchadosDaAbaObservacoes()` tira `variacao_atipica_dre` da lista, do donut e dos cards de categoria, e o grupo "Variações e Indicadores" saiu de `PID_DC_GRUPOS`. Continuam contando em `total_achados_pendentes` da listagem enquanto não validadas.
+- **O texto não é `observacao_contador`**: decisão do usuário, "os textos devem funcionar da mesma maneira que as observações feitas nas contas". O botão de observação do card abre a thread de `ContabilObservacao` da **própria linha** da Análise Vertical (histórico entre competências, "mostrar ao cliente", encerrar/excluir). É a mesma thread da tabela: escrever pelo card aparece na linha e vice-versa, e no relatório a observação sai em "Observações da Análise Vertical" quando marcada para o cliente.
+- A thread é montada por `dcObsPainelConteudoHtml(tipo, alvoId, observacoes, concluida, escopo)`: `escopo="variacao"` separa os ids dos campos e a chave em `dcObsPainelAberto` (`variacao`), e o atributo `data-dc-obs-criar` ganha um terceiro pedaço com o escopo. Abrir o painel do card fecha o da tabela e vice-versa, senão a mesma thread existiria duas vezes na tela com os mesmos ids de edição.
+
### Aba "Dashboard"
Mostra, **antes de gerar o relatório**, os mesmos cards de indicador e a mesma lista de observações que vão para o relatório, com um botão de olho em cada item para escondê-lo do relatório final sem apagar o dado.
@@ -490,6 +522,7 @@ Cor por sinal só onde é seguro: `sign` (ROA/ROE/EBIT/EBITDA — positivo verde
## Riscos conhecidos e armadilhas
+- **Leiaute Contabit calibrado contra um único arquivo** — ver "Leiaute Contabit": códigos das regras, passos de nível e a premissa do sinal negativo precisam ser conferidos a cada arquivo novo de outro cliente.
- **Códigos de classificação fixos** — se um cliente usar plano de contas com numeração diferente, indicadores e a regra de caixa saem errados **silenciosamente**. Revisar contra mais balancetes reais de empresas diferentes antes de confiar cegamente num valor exibido ao cliente.
- **Descrições coladas em PDF de fonte atípica** — não têm correção segura; a ferramenta avisa por badge. Valores monetários nunca são afetados.
- **Duas cópias mantidas à mão**: os SVGs de ícone (Python + JS), a lógica de árvore/destaque (tela de revisão + relatório) e a montagem do caminho na chave de observação (`chaves.caminhos_linhas()` em Python, `dcAplicaChavesObs()` no JS, mais uma terceira cópia congelada dentro da migração `0078`, que por definição não pode importar código de aplicação). Mudança num lado exige o outro.
diff --git a/portal_api/dashboard_contabil/README.md b/portal_api/dashboard_contabil/README.md
index 68397bb..1b33e57 100644
--- a/portal_api/dashboard_contabil/README.md
+++ b/portal_api/dashboard_contabil/README.md
@@ -2,13 +2,13 @@
> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico (extração do PDF, regras de auditoria, models). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Chamado de "Dashboard Contábil" até uma rodada anterior — renomeado a pedido do usuário (rótulo visível só, nomes técnicos internos continuam `dashboard_contabil`/`Contabil*`).
-Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil: o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor hoje enviado ao cliente), a ferramenta extrai as contas automaticamente e roda um conjunto de checagens de auditoria (saldos negativos de caixa, contas transitórias/genéricas com saldo, contas que deveriam estar zeradas, diferença débito/crédito, lucro do balancete diferente da DRE, variações atípicas na DRE) — o contador revisa os achados, marca contas/linhas como validadas, registra observações por conta/linha (thread inline na própria tabela, com um toggle "mostrar ao cliente" que nasce desmarcado), escreve um resumo livre do fechamento (considerações/análises, texto rico com imagem) e conclui a análise. As observações não morrem com a competência: cada uma fica no histórico daquela empresa + conta, reaparecendo na análise do mês seguinte assinada por quem escreveu e com a data, travada pra edição, até o contador encerrá-la para as próximas execuções. Quando o PDF traz a seção "Demonstração Mensal (Análise Vertical)" (histórico de 3 meses, valor+variação percentual por linha), ela também é extraída e ganha sua própria aba de revisão (mesmos recursos de observação/validado/ocultar do Balancete/D.R.E.). Se o arquivo original estava errado/incompleto, o botão "Reprocessar" reanexa um PDF novo pra mesma empresa/competência sem perder nada já registrado — conta/linha sem mudança mantém observação e validação, a que mudou volta pra "não validada" com um alerta, e achado já tratado nunca é apagado ou tem sua justificativa sobrescrita. O botão "Gerar Relatório" gera um relatório HTML autocontido (resumo do fechamento, indicadores financeiros, DRE/Balancete/Análise Vertical agrupados, observações do contador) com a marca do escritório, pronto pra enviar ao administrador da empresa — a aba "Resumo" desse relatório também pode ser exportada avulsa em PDF (resumo do fechamento + indicadores + observações, sem o resto do relatório).
+Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil: o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório hoje enviado ao cliente, gerado pelo Questor ou pelo Contabit; o modelo é detectado sozinho, e no Contabit o código da empresa vem do nome do arquivo, ex. "1512 - Balancete 082026.pdf"), a ferramenta extrai as contas automaticamente e roda um conjunto de checagens de auditoria (saldos negativos de caixa, contas transitórias/genéricas com saldo, contas que deveriam estar zeradas, diferença débito/crédito, lucro do balancete diferente da DRE, variações atípicas na DRE) — o contador revisa os achados, marca contas/linhas como validadas, registra observações por conta/linha (thread inline na própria tabela, com um toggle "mostrar ao cliente" que nasce desmarcado), escreve um resumo livre do fechamento (considerações/análises, texto rico com imagem) e conclui a análise. As observações não morrem com a competência: cada uma fica no histórico daquela empresa + conta, reaparecendo na análise do mês seguinte assinada por quem escreveu e com a data, travada pra edição, até o contador encerrá-la para as próximas execuções. Quando o PDF traz a seção "Demonstração Mensal (Análise Vertical)" (histórico de 3 meses, valor+variação percentual por linha), ela também é extraída e ganha sua própria aba de revisão (mesmos recursos de observação/validado/ocultar do Balancete/D.R.E.). Se o arquivo original estava errado/incompleto, o botão "Reprocessar" reanexa um PDF novo pra mesma empresa/competência sem perder nada já registrado — conta/linha sem mudança mantém observação e validação, a que mudou volta pra "não validada" com um alerta, e achado já tratado nunca é apagado ou tem sua justificativa sobrescrita. O botão "Gerar Relatório" gera um relatório HTML autocontido (resumo do fechamento, indicadores financeiros, DRE/Balancete/Análise Vertical agrupados, observações do contador) com a marca do escritório, pronto pra enviar ao administrador da empresa — a aba "Resumo" desse relatório também pode ser exportada avulsa em PDF (resumo do fechamento + indicadores + observações, sem o resto do relatório).
-**Ainda fora de escopo**: exportação em XLSX; consolidação entre várias empresas/competências ao mesmo tempo (é sempre uma apuração por vez).
+**Ainda fora de escopo**: indicadores para empresas do Contabit (a seção fica oculta nesse modelo); consolidação entre várias empresas/competências ao mesmo tempo (é sempre uma apuração por vez).
## Onde mexer
-- `portal_api/dashboard_contabil/` — `parser.py` (extração do PDF via `pdfplumber`), `regras.py` (motor de regras de auditoria), `indicadores.py` (indicadores financeiros do relatório), `pipeline.py` (orquestração), `modelos.py` (dataclasses), `chaves.py` (chave natural de conta/linha), `exportacao.py` (Balancete/DRE em XLSX), `resumo_pdf.py` (Resumo do Fechamento + Indicadores + Observações em PDF, avulso).
+- `portal_api/dashboard_contabil/` — `parser.py` (extração do PDF Questor via `pdfplumber` e detecção do modelo), `parser_contabit.py` (extração do PDF Contabit), `regras.py` (motor de regras de auditoria), `indicadores.py` (indicadores financeiros do relatório), `pipeline.py` (orquestração), `modelos.py` (dataclasses), `chaves.py` (chave natural de conta/linha), `exportacao.py` (Balancete/DRE em XLSX), `resumo_pdf.py` (Resumo do Fechamento + Indicadores + Observações em PDF, avulso).
- `ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical`/`ContabilAchado`/`ContabilObservacao`/`ContabilObservacaoEdicao` (`portal_api/models.py`).
- `dashboard-contabil.html` / `static/js/dashboard-contabil.js` / `static/css/dashboard-contabil.css`.
- `dashboard-contabil-relatorio.html` (relatório em si) / `portal_api/templatetags/contabil_extras.py` (filtros de formatação).
diff --git a/portal_api/dashboard_contabil/conversao.py b/portal_api/dashboard_contabil/conversao.py
new file mode 100644
index 0000000..2875999
--- /dev/null
+++ b/portal_api/dashboard_contabil/conversao.py
@@ -0,0 +1,56 @@
+"""Conversões e erros compartilhados pelos parsers de PDF (`parser.py`, leiaute
+Questor, e `parser_contabit.py`, leiaute Contabit). Vivem aqui, e não em
+`parser.py`, só pra evitar import circular: `parser.py` despacha para
+`parser_contabit.py`, que precisa das mesmas conversões. `parser.py`
+reexporta `ExtracaoInvalidaError`, então quem já importava de lá continua
+funcionando."""
+
+from __future__ import annotations
+
+from datetime import date
+from decimal import Decimal, InvalidOperation
+
+
+class ExtracaoInvalidaError(Exception):
+ """PDF não tem o formato esperado (cabeçalho/tabela do Balancete/DRE não
+ encontrados) — a view converte isso numa resposta 400 genérica, mesmo
+ padrão de `planos_saude`/`indicadores`."""
+
+
+class CodigoEmpresaAusenteError(ExtracaoInvalidaError):
+ """O leiaute Contabit não traz o código da empresa no PDF, então ele vem
+ do nome do arquivo ("1512 - Balancete 082026.pdf"). Subclasse própria pra
+ a view mostrar uma mensagem específica em vez da genérica de formato
+ inválido: o PDF em si está certo, só o nome do arquivo não segue o
+ padrão."""
+
+
+def para_decimal(texto: str) -> Decimal:
+ texto = texto.strip()
+ # Algumas empresas têm linhas de DRE negativas no PDF com só o parêntese
+ # de abertura ("(1.114.989,29", sem o "()" de fechamento) — confirmado
+ # direto nos caracteres brutos do PDF (não é um artefato de
+ # _reconstroi_linhas), então o "(" sozinho já basta pra marcar negativo.
+ negativo = texto.startswith("(")
+ if negativo:
+ texto = texto[1:]
+ if texto.endswith(")"):
+ texto = texto[:-1]
+ texto = texto.replace(".", "").replace(",", ".")
+ try:
+ valor = Decimal(texto)
+ except InvalidOperation as exc:
+ raise ExtracaoInvalidaError(f"Valor monetário inválido: {texto!r}") from exc
+ return -valor if negativo else valor
+
+
+def para_percentual(texto: str) -> Decimal:
+ """Mesma conversão de `para_decimal`, só removendo o "%" antes — o sinal
+ negativo continua expresso só pelo parêntese (ex. "(7,33%)") no Questor,
+ ou pelo "-" no Contabit, igual ao valor monetário."""
+ return para_decimal(texto.replace("%", ""))
+
+
+def para_data(texto: str) -> date:
+ dia, mes, ano = texto.split("/")
+ return date(int(ano), int(mes), int(dia))
diff --git a/portal_api/dashboard_contabil/modelos.py b/portal_api/dashboard_contabil/modelos.py
index 4ddc025..6f946c5 100644
--- a/portal_api/dashboard_contabil/modelos.py
+++ b/portal_api/dashboard_contabil/modelos.py
@@ -9,6 +9,13 @@ from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
+# Sistema contábil que gerou o PDF — detectado pelo próprio parser
+# (`parser.extrai_balancete_dre()`), nunca informado pelo usuário. Muda o
+# plano de contas (códigos fixos das regras), a convenção de sinal dos
+# saldos e se a apuração tem indicadores (ver CLAUDE.md do pacote).
+LEIAUTE_QUESTOR = "questor"
+LEIAUTE_CONTABIT = "contabit"
+
@dataclass
class CabecalhoExtraido:
@@ -21,7 +28,8 @@ class CabecalhoExtraido:
@dataclass
class LinhaBalanceteExtraida:
- conta_numero: int
+ # `None` só no Contabit, onde a conta sintética não tem número próprio.
+ conta_numero: int | None
codigo: str
descricao: str
tipo: str # "S" (sintética) ou "A" (analítica)
@@ -76,6 +84,7 @@ class ResultadoExtracao:
# `ContabilApuracao.fonte_pdf_atipica`, só pra alertar o contador — não
# bloqueia nem tenta corrigir nada automaticamente.
fonte_pdf_atipica: bool = False
+ leiaute: str = LEIAUTE_QUESTOR
@dataclass
diff --git a/portal_api/dashboard_contabil/parser.py b/portal_api/dashboard_contabil/parser.py
index d88be55..febbd1f 100644
--- a/portal_api/dashboard_contabil/parser.py
+++ b/portal_api/dashboard_contabil/parser.py
@@ -1,6 +1,6 @@
-"""Extração do PDF de Balancete + DRE (leiaute Questor, único leiaute-fonte —
-ao contrário de `portal_api.planos_saude`, que precisa de um parser por
-operadora, aqui o relatório é sempre gerado pelo mesmo sistema contábil).
+"""Extração do PDF de Balancete + DRE. Dois leiautes: Questor (este módulo) e
+Contabit (`parser_contabit.py`) — `extrai_balancete_dre()` detecta qual é
+pela primeira página e despacha; o resto deste docstring trata do Questor.
O texto deste relatório é desenhado caractere a caractere (cada letra/número
tem sua própria posição X, sem depender de kerning do PDF) e o próprio PDF
@@ -19,12 +19,15 @@ from __future__ import annotations
import re
import unicodedata
-from datetime import date
-from decimal import Decimal, InvalidOperation
from typing import IO
import pdfplumber
+from . import parser_contabit
+from .conversao import CodigoEmpresaAusenteError, ExtracaoInvalidaError # noqa: F401 — reexportados
+from .conversao import para_data as _para_data
+from .conversao import para_decimal as _para_decimal
+from .conversao import para_percentual as _para_percentual
from .modelos import (
CabecalhoExtraido,
LinhaAnaliseVerticalExtraida,
@@ -95,43 +98,6 @@ _RE_MES_ANALISE_VERTICAL = re.compile(r"([A-Za-z]{3})\s*-\s*(\d{4})")
LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRAS = "DESPESAS/RECEITAS FINANCEIRAS"
-class ExtracaoInvalidaError(Exception):
- """PDF não tem o formato esperado (cabeçalho/tabela do Balancete/DRE não
- encontrados) — a view converte isso numa resposta 400 genérica, mesmo
- padrão de `planos_saude`/`indicadores`."""
-
-
-def _para_decimal(texto: str) -> Decimal:
- texto = texto.strip()
- # Algumas empresas têm linhas de DRE negativas no PDF com só o parêntese
- # de abertura ("(1.114.989,29", sem o "()" de fechamento) — confirmado
- # direto nos caracteres brutos do PDF (não é um artefato de
- # _reconstroi_linhas), então o "(" sozinho já basta pra marcar negativo.
- negativo = texto.startswith("(")
- if negativo:
- texto = texto[1:]
- if texto.endswith(")"):
- texto = texto[:-1]
- texto = texto.replace(".", "").replace(",", ".")
- try:
- valor = Decimal(texto)
- except InvalidOperation as exc:
- raise ExtracaoInvalidaError(f"Valor monetário inválido: {texto!r}") from exc
- return -valor if negativo else valor
-
-
-def _para_percentual(texto: str) -> Decimal:
- """Mesma conversão de `_para_decimal`, só removendo o "%" antes — o sinal
- negativo continua expresso só pelo parêntese (ex. "(7,33%)"), igual ao
- valor monetário."""
- return _para_decimal(texto.replace("%", ""))
-
-
-def _para_data(texto: str) -> date:
- dia, mes, ano = texto.split("/")
- return date(int(ano), int(mes), int(dia))
-
-
def _reconstroi_linhas(pagina) -> list[dict]:
caracteres = [c for c in pagina.chars if c["text"] != " "]
linhas: dict[float, list] = {}
@@ -192,9 +158,14 @@ def _extrai_cabecalho(primeiras_linhas: list[str]) -> CabecalhoExtraido:
)
-def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
- """Lê o PDF combinado de Balancete + DRE (modelo Questor) e devolve tudo
- já estruturado. `origem` aceita tanto um caminho em disco quanto um
+def extrai_balancete_dre(origem: str | IO[bytes], nome_arquivo: str | None = None) -> ResultadoExtracao:
+ """Lê o PDF combinado de Balancete + DRE e devolve tudo já estruturado.
+ Um PDF do Contabit (detectado pela primeira página) vai para
+ `parser_contabit.extrai()`, que usa `nome_arquivo` pra obter o código da
+ empresa (o PDF não traz); o Questor segue abaixo, lendo o código do
+ próprio PDF e ignorando `nome_arquivo`.
+
+ `origem` aceita tanto um caminho em disco quanto um
arquivo já aberto em memória (`io.BytesIO`) — a view chama isto direto
sobre o upload, antes de saber `codigo_empresa`/`competencia` (extraídos
daqui), então ainda não há um caminho em disco definitivo nesse momento.
@@ -227,6 +198,8 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
with pdfplumber.open(origem) as pdf:
if not pdf.pages:
raise ExtracaoInvalidaError("O PDF não tem páginas.")
+ if parser_contabit.eh_leiaute_contabit(pdf.pages[0]):
+ return parser_contabit.extrai(pdf, nome_arquivo)
primeiras_linhas_pagina1 = [
linha["texto"] for linha in _reconstroi_linhas(pdf.pages[0]) if linha["texto"].strip()
diff --git a/portal_api/dashboard_contabil/parser_contabit.py b/portal_api/dashboard_contabil/parser_contabit.py
new file mode 100644
index 0000000..0f8b535
--- /dev/null
+++ b/portal_api/dashboard_contabil/parser_contabit.py
@@ -0,0 +1,342 @@
+"""Extração do PDF de Balancete + DRE gerado pelo Contabit (o segundo sistema
+contábil usado pelo escritório). `parser.extrai_balancete_dre()` detecta o
+leiaute pela primeira página (`eh_leiaute_contabit()`) e despacha pra cá;
+o resultado sai no mesmo `ResultadoExtracao` do Questor, com
+`leiaute="contabit"`.
+
+Calibrado contra `1512 - Balancete 082026.pdf` (arquivo de referência, fora
+do repositório, em `Projetos\\Balancetes\\Contabit`). Diferenças de leiaute
+que moldam este módulo:
+
+- **Sem código da empresa no PDF.** O cabeçalho só tem nome e CNPJ; o código
+ vem do prefixo numérico do nome do arquivo (decisão explícita do usuário).
+- **Balancete: classificação, descrição, nº da conta, 4 valores.** Não existe
+ flag S/A: conta sintética é a que não tem nº de conta.
+- **Descrição longa "vaza" por cima das colunas seguintes.** O PDF corta a
+ descrição na tela (clip), mas o texto inteiro continua no fluxo e seus
+ caracteres ficam na mesma faixa horizontal do nº da conta e até do Saldo
+ Anterior. Ordenar por `x0` intercala os dois ("GRAFICOS2 2L9T3D5A" =
+ "GRAFICOS LTDA" + "22935"). Por isso o Balancete é lido na **ordem do
+ fluxo do PDF** (descrição, depois os valores, depois o nº da conta, cada um
+ um trecho contínuo), e só a posição dos 4 valores entre si usa `x0`.
+- **Saldos pela natureza da conta.** Passivo e contas redutoras aparecem
+ positivos (não negativos como no Questor); o grupo pai já faz a subtração.
+- **DRE com negativo em "-"** (não entre parênteses) e, nas linhas em negrito,
+ o valor desenhado 1pt abaixo do texto — por isso as linhas são agrupadas
+ com tolerância vertical, não por `round(top)`.
+- **Demonstração mensal** com meses por extenso ("JUNHO/2026"), AV% sem "%",
+ uma coluna TOTAL a mais (soma dos meses mostrados, descartada) e linhas
+ espaçadoras só com zeros, sem descrição (ignoradas)."""
+
+from __future__ import annotations
+
+import os
+import re
+
+from .conversao import CodigoEmpresaAusenteError, ExtracaoInvalidaError, para_data, para_decimal, para_percentual
+from .modelos import (
+ LEIAUTE_CONTABIT,
+ CabecalhoExtraido,
+ LinhaAnaliseVerticalExtraida,
+ LinhaBalanceteExtraida,
+ LinhaDreExtraida,
+ ResultadoExtracao,
+ ValorMensalAnaliseVertical,
+)
+
+# Tolerância vertical (pt) pra considerar dois caracteres da mesma linha
+# visual. Cobre o valor da linha em negrito da DRE (1pt abaixo do texto) e a
+# linha de AV% da demonstração mensal (1pt abaixo do valor); duas linhas
+# reais de tabela ficam sempre ~11pt uma da outra.
+_TOLERANCIA_LINHA = 1.6
+
+# Vão horizontal (pt) acima do qual dois caracteres consecutivos (ordenados
+# por x) viram campos diferentes. Diferente do Questor, aqui o próprio PDF
+# desenha os espaços entre palavras como caractere, então o vão só precisa
+# separar colunas.
+_GAP_CAMPO = 1.0
+
+# Leitura em ordem de fluxo (Balancete): um trecho novo começa quando o
+# próximo caractere volta pra trás (além do que o kerning explica, ex. "LT"
+# em "LTDA") ou pula pra frente além de um espaço normal.
+_RECUO_MAXIMO_KERNING = 2.0
+_SALTO_MAXIMO_TRECHO = 4.0
+
+# Recuo (pt) de cada nível da árvore. DRE: 67.7 → 75.2 → 82.6 → 90.1;
+# demonstração mensal: 30.7 → 36.7 → 42.6 → 48.6.
+_PASSO_NIVEL_DRE = 7.45
+_PASSO_NIVEL_MENSAL = 5.95
+
+_RE_MONETARIO_EXATO = re.compile(r"^-?[\d.]+,\d{2}$")
+_RE_MONETARIO = re.compile(r"-?[\d.]+,\d{2}")
+_RE_CLASSIFICACAO = re.compile(r"^(?P\d+(?:\.\d+)*)\s+(?P.+)$")
+_RE_NUMERO_CONTA = re.compile(r"^\d+$")
+_RE_CNPJ = re.compile(r"CNPJ:\s*([\d./-]+)")
+_RE_PERIODO = re.compile(r"PER[IÍ]ODO DE\s*(\d{2}/\d{2}/\d{4})\s*[AÀ]\s*(\d{2}/\d{2}/\d{4})", re.IGNORECASE)
+_RE_MES_MENSAL = re.compile(r"([A-ZÇ]+)/(\d{4})")
+_RE_CODIGO_NO_NOME_ARQUIVO = re.compile(r"^\s*(\d+)")
+
+_MESES_ABREVIADOS = {
+ "JANEIRO": "jan",
+ "FEVEREIRO": "fev",
+ "MARCO": "mar",
+ "MARÇO": "mar",
+ "ABRIL": "abr",
+ "MAIO": "mai",
+ "JUNHO": "jun",
+ "JULHO": "jul",
+ "AGOSTO": "ago",
+ "SETEMBRO": "set",
+ "OUTUBRO": "out",
+ "NOVEMBRO": "nov",
+ "DEZEMBRO": "dez",
+}
+
+_SECAO_BALANCETE = "balancete"
+_SECAO_DRE = "dre"
+_SECAO_MENSAL = "mensal"
+
+
+def _agrupa_linhas(pagina) -> list[list[dict]]:
+ """Caracteres da página agrupados por linha visual, **preservando a ordem
+ do fluxo do PDF** dentro de cada linha (é o que `pagina.chars` já
+ entrega). Linhas em ordem de `top`."""
+ grupos: list[tuple[float, list[dict]]] = []
+ for c in sorted(pagina.chars, key=lambda c: c["top"]):
+ if grupos and c["top"] - grupos[-1][0] <= _TOLERANCIA_LINHA:
+ grupos[-1][1].append(c)
+ else:
+ grupos.append((c["top"], [c]))
+ ordem_fluxo = {id(c): i for i, c in enumerate(pagina.chars)}
+ return [sorted(chars, key=lambda c: ordem_fluxo[id(c)]) for _, chars in grupos]
+
+
+def _texto_por_posicao(chars: list[dict]) -> str:
+ """Texto da linha lido da esquerda pra direita, espaços colapsados."""
+ texto = ""
+ anterior_x1 = None
+ for c in sorted(chars, key=lambda c: c["x0"]):
+ if anterior_x1 is not None and c["x0"] - anterior_x1 > _GAP_CAMPO:
+ texto += " "
+ texto += c["text"]
+ anterior_x1 = c["x1"]
+ return " ".join(texto.split())
+
+
+def _trechos_por_fluxo(chars: list[dict]) -> list[tuple[str, float]]:
+ """Trechos contínuos da linha na ordem do fluxo do PDF, cada um com o
+ `x0` do primeiro caractere não-espaço (ver docstring do módulo)."""
+ trechos: list[tuple[str, float]] = []
+ atual = ""
+ x0_atual: float | None = None
+ anterior = None
+ for c in chars:
+ if anterior is not None and (
+ c["x0"] < anterior["x1"] - _RECUO_MAXIMO_KERNING or c["x0"] - anterior["x1"] > _SALTO_MAXIMO_TRECHO
+ ):
+ if atual.strip():
+ trechos.append((" ".join(atual.split()), x0_atual))
+ atual, x0_atual = "", None
+ atual += c["text"]
+ if x0_atual is None and c["text"].strip():
+ x0_atual = c["x0"]
+ anterior = c
+ if atual.strip():
+ trechos.append((" ".join(atual.split()), x0_atual))
+ return trechos
+
+
+def _x0_texto(chars: list[dict]) -> float:
+ return min(c["x0"] for c in chars if c["text"].strip())
+
+
+def _negrito(chars: list[dict]) -> bool:
+ return any("bold" in c["fontname"].lower() for c in chars if c["text"].strip())
+
+
+def eh_leiaute_contabit(primeira_pagina) -> bool:
+ """O cabeçalho do Contabit tem "Classificação/Conta" (coluna única) e o
+ período no formato "PERÍODO DE ... À ..."; o Questor usa "Período:" e
+ colunas separadas de conta/classificação."""
+ textos = [_texto_por_posicao(chars) for chars in _agrupa_linhas(primeira_pagina)[:12]]
+ return any("Classificação/Conta" in t for t in textos) and any(_RE_PERIODO.search(t) for t in textos)
+
+
+def codigo_empresa_do_nome_arquivo(nome_arquivo: str | None) -> str:
+ """Prefixo numérico do nome do arquivo ("1512 - Balancete 082026.pdf" →
+ "1512"). Sem prefixo numérico, a análise não tem como ser identificada
+ (é a chave natural junto da competência) e o upload é recusado."""
+ base = os.path.basename(nome_arquivo or "")
+ match = _RE_CODIGO_NO_NOME_ARQUIVO.match(base)
+ if not match:
+ raise CodigoEmpresaAusenteError(
+ "No modelo Contabit o código da empresa vem do nome do arquivo, que precisa começar "
+ 'pelo código (ex.: "1512 - Balancete 082026.pdf").'
+ )
+ return match.group(1)
+
+
+def _extrai_cabecalho(primeira_pagina, nome_arquivo: str | None) -> CabecalhoExtraido:
+ textos = [_texto_por_posicao(chars) for chars in _agrupa_linhas(primeira_pagina)[:12]]
+ texto_completo = " ".join(textos)
+ match_cnpj = _RE_CNPJ.search(texto_completo)
+ match_periodo = _RE_PERIODO.search(texto_completo)
+ if not match_cnpj or not match_periodo:
+ raise ExtracaoInvalidaError("Não foi possível localizar CNPJ/período no cabeçalho do PDF.")
+
+ # O nome da empresa é a linha logo acima do "CNPJ:".
+ indice_cnpj = next(i for i, t in enumerate(textos) if t.startswith("CNPJ:"))
+ nome_empresa = textos[indice_cnpj - 1].strip() if indice_cnpj > 0 else ""
+ if not nome_empresa or _RE_PERIODO.search(nome_empresa):
+ raise ExtracaoInvalidaError("Não foi possível localizar o nome da empresa no cabeçalho do PDF.")
+
+ return CabecalhoExtraido(
+ codigo_empresa=codigo_empresa_do_nome_arquivo(nome_arquivo),
+ nome_empresa=nome_empresa,
+ cnpj=match_cnpj.group(1),
+ periodo_inicio=para_data(match_periodo.group(1)),
+ periodo_fim=para_data(match_periodo.group(2)),
+ )
+
+
+def _secao_da_pagina(pagina) -> str | None:
+ textos = [_texto_por_posicao(chars) for chars in _agrupa_linhas(pagina)[:8]]
+ cabecalho = " ".join(textos).upper()
+ if "CLASSIFICAÇÃO/CONTA" in cabecalho:
+ return _SECAO_BALANCETE
+ if "RESULTADO ACUMULADO" in cabecalho:
+ return _SECAO_DRE
+ if "MENSAL" in cabecalho and "DEMONSTRAÇÃO DO RESULTADO" in cabecalho:
+ return _SECAO_MENSAL
+ return None
+
+
+def _linha_balancete(chars: list[dict]) -> LinhaBalanceteExtraida | None:
+ trechos = _trechos_por_fluxo(chars)
+ if not trechos:
+ return None
+ valores = [(texto, x0) for texto, x0 in trechos if _RE_MONETARIO_EXATO.match(texto)]
+ numeros_conta = [texto for texto, _ in trechos if _RE_NUMERO_CONTA.match(texto)]
+ descricao_trechos = [
+ texto for texto, _ in trechos if not _RE_MONETARIO_EXATO.match(texto) and not _RE_NUMERO_CONTA.match(texto)
+ ]
+ match = _RE_CLASSIFICACAO.match(" ".join(descricao_trechos))
+ if not match:
+ return None
+ if len(valores) != 4 or len(numeros_conta) > 1:
+ raise ExtracaoInvalidaError(f"Linha de balancete com colunas inesperadas: {match.group('codigo')}.")
+ saldo_anterior, debito, credito, saldo_atual = (texto for texto, _ in sorted(valores, key=lambda v: v[1]))
+ return LinhaBalanceteExtraida(
+ conta_numero=int(numeros_conta[0]) if numeros_conta else None,
+ codigo=match.group("codigo"),
+ descricao=match.group("descricao").strip(),
+ tipo="A" if numeros_conta else "S",
+ saldo_anterior=para_decimal(saldo_anterior),
+ debito=para_decimal(debito),
+ credito=para_decimal(credito),
+ saldo_atual=para_decimal(saldo_atual),
+ )
+
+
+def _separa_descricao_valores(chars: list[dict]) -> tuple[str, list[str]]:
+ texto = _texto_por_posicao(chars)
+ primeiro = _RE_MONETARIO.search(texto)
+ if not primeiro:
+ return texto, []
+ return texto[: primeiro.start()].strip(), _RE_MONETARIO.findall(texto[primeiro.start():])
+
+
+def _meses_do_cabecalho(texto: str) -> list[str]:
+ meses = []
+ for mes, ano in _RE_MES_MENSAL.findall(texto.upper()):
+ abreviado = _MESES_ABREVIADOS.get(mes)
+ if abreviado is None:
+ raise ExtracaoInvalidaError(f"Mês não reconhecido no cabeçalho da demonstração mensal: {mes!r}.")
+ meses.append(f"{abreviado}/{ano}")
+ return meses
+
+
+def extrai(pdf, nome_arquivo: str | None) -> ResultadoExtracao:
+ """Lê um PDF Contabit já aberto (`pdfplumber.PDF`) — ver
+ `parser.extrai_balancete_dre()`, que abre o arquivo e despacha pra cá."""
+ cabecalho = _extrai_cabecalho(pdf.pages[0], nome_arquivo)
+ contas: list[LinhaBalanceteExtraida] = []
+ linhas_dre: list[LinhaDreExtraida] = []
+ linhas_mensal: list[LinhaAnaliseVerticalExtraida] = []
+ meses: list[str] | None = None
+ base_x0_dre: float | None = None
+ base_x0_mensal: float | None = None
+
+ for pagina in pdf.pages:
+ secao = _secao_da_pagina(pagina)
+ if secao is None:
+ continue
+ for chars in _agrupa_linhas(pagina):
+ if not any(c["text"].strip() for c in chars):
+ continue
+ if secao == _SECAO_BALANCETE:
+ conta = _linha_balancete(chars)
+ if conta is not None:
+ contas.append(conta)
+ continue
+
+ if secao == _SECAO_MENSAL and meses is None:
+ candidatos = _meses_do_cabecalho(_texto_por_posicao(chars))
+ if candidatos:
+ meses = candidatos
+ continue
+
+ descricao, valores = _separa_descricao_valores(chars)
+ # Linha sem descrição (espaçadora "0,00 0,00", valor solto) ou
+ # sem valor (título, cabeçalho, assinatura) não é dado.
+ if not descricao or not valores:
+ continue
+ x0 = _x0_texto(chars)
+ if secao == _SECAO_DRE:
+ if base_x0_dre is None:
+ base_x0_dre = x0
+ linhas_dre.append(
+ LinhaDreExtraida(
+ ordem=len(linhas_dre) + 1,
+ descricao=descricao,
+ nivel=max(0, round((x0 - base_x0_dre) / _PASSO_NIVEL_DRE)),
+ valor=para_decimal(valores[-1]),
+ totalizador=_negrito(chars),
+ )
+ )
+ else:
+ if meses is None:
+ raise ExtracaoInvalidaError("Cabeçalho de meses da demonstração mensal não encontrado.")
+ # Pares valor + AV% por mês, mais o par da coluna TOTAL no fim.
+ if len(valores) != 2 * (len(meses) + 1):
+ raise ExtracaoInvalidaError(f"Linha da demonstração mensal com colunas inesperadas: {descricao!r}.")
+ if base_x0_mensal is None:
+ base_x0_mensal = x0
+ linhas_mensal.append(
+ LinhaAnaliseVerticalExtraida(
+ ordem=len(linhas_mensal) + 1,
+ descricao=descricao,
+ nivel=max(0, round((x0 - base_x0_mensal) / _PASSO_NIVEL_MENSAL)),
+ totalizador=_negrito(chars),
+ valores=[
+ ValorMensalAnaliseVertical(
+ valor=para_decimal(valores[2 * i]), percentual=para_percentual(valores[2 * i + 1])
+ )
+ for i in range(len(meses))
+ ],
+ )
+ )
+
+ if not contas:
+ raise ExtracaoInvalidaError("Nenhuma linha de balancete encontrada no PDF.")
+ if not linhas_dre:
+ raise ExtracaoInvalidaError("Nenhuma linha de DRE encontrada no PDF.")
+
+ return ResultadoExtracao(
+ cabecalho=cabecalho,
+ contas=contas,
+ linhas_dre=linhas_dre,
+ meses_analise_vertical=meses or [],
+ linhas_analise_vertical=linhas_mensal,
+ leiaute=LEIAUTE_CONTABIT,
+ )
diff --git a/portal_api/dashboard_contabil/pipeline.py b/portal_api/dashboard_contabil/pipeline.py
index e0698ab..28aca84 100644
--- a/portal_api/dashboard_contabil/pipeline.py
+++ b/portal_api/dashboard_contabil/pipeline.py
@@ -19,9 +19,13 @@ from .regras import gera_achados
def processa_apuracao(
- origem: str | IO[bytes], busca_historico: Callable[[CabecalhoExtraido], list[SnapshotHistorico]]
+ origem: str | IO[bytes],
+ busca_historico: Callable[[CabecalhoExtraido], list[SnapshotHistorico]],
+ nome_arquivo: str | None = None,
) -> ResultadoProcessamento:
- extracao = extrai_balancete_dre(origem)
+ """`nome_arquivo` só é usado no leiaute Contabit, que tira dele o código
+ da empresa (ver `parser_contabit.codigo_empresa_do_nome_arquivo`)."""
+ extracao = extrai_balancete_dre(origem, nome_arquivo)
historico = busca_historico(extracao.cabecalho)
achados = gera_achados(extracao, historico)
return ResultadoProcessamento(extracao=extracao, achados=achados)
diff --git a/portal_api/dashboard_contabil/regras.py b/portal_api/dashboard_contabil/regras.py
index 1fd88c5..00a6313 100644
--- a/portal_api/dashboard_contabil/regras.py
+++ b/portal_api/dashboard_contabil/regras.py
@@ -11,7 +11,13 @@ from __future__ import annotations
import re
from decimal import Decimal
-from .modelos import AchadoDetectado, LinhaBalanceteExtraida, ResultadoExtracao, SnapshotHistorico
+from .modelos import (
+ LEIAUTE_CONTABIT,
+ AchadoDetectado,
+ LinhaBalanceteExtraida,
+ ResultadoExtracao,
+ SnapshotHistorico,
+)
# Exclui "NUMERÁRIOS EM TRANSITO"/"... EM TRANSITO" (dinheiro em trânsito,
# conceito diferente de conta transitória/de compensação) do match de
@@ -46,6 +52,15 @@ TRECHOS_CONTA_DEVERIA_ZERAR = ["ADIANTAMENTOS DE SALÁRIOS", "ADIANTAMENTO DE SA
# saldo desta linha agregadora, não de uma das duas filhas específicas.
CODIGO_LUCRO_PREJUIZO_EXERCICIO = "2.04.13.002"
+# Equivalentes no plano de contas do Contabit, calibrados contra `1512 -
+# Balancete 082026.pdf` (único arquivo Contabit de referência até aqui):
+# "2.40.40.20 LUCROS / PREJUIZOS DO EXERCÍCIO" e "1.10.10.01 BENS NUMERÁRIOS"
+# (o grupo que contém "Caixa"). Mesmo risco dos códigos do Questor: se outro
+# cliente Contabit usar numeração diferente, a regra deixa de disparar.
+CODIGO_LUCRO_PREJUIZO_EXERCICIO_CONTABIT = "2.40.40.20"
+PREFIXO_CAIXA_QUESTOR = "1.01.01.001"
+PREFIXO_CAIXA_CONTABIT = "1.10.10.01"
+
VARIACAO_LIMIAR_PERCENTUAL = Decimal("0.65") # 65%
VARIACAO_VALOR_MINIMO = Decimal("1000") # ignora variações abaixo disso, mesmo que %-mente grandes
# Piso em pontos percentuais pra regra_variacao_atipica_dre (Análise Vertical)
@@ -55,6 +70,11 @@ VARIACAO_VALOR_MINIMO = Decimal("1000") # ignora variações abaixo disso, mesm
# relativa, mas irrelevante em termos de composição da receita).
VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO = Decimal("1")
+# Nome pedido pelo usuário pro apontamento da regra de variação: deixa claro
+# que ele vem da Análise Vertical, não da DRE acumulada. A migração `0081`
+# aplicou o mesmo título aos apontamentos já gravados.
+TITULO_VARIACAO_ATIPICA_DRE = "Variação atípica na DRE - Análise Vertical"
+
def _moeda(valor: Decimal) -> str:
"""Formata no padrão brasileiro (ponto de milhar, vírgula decimal) —
@@ -74,7 +94,10 @@ def regra_balanceamento_ativo_passivo(atual: ResultadoExtracao, historico: list[
passivo = next((c for c in atual.contas if c.codigo == "2"), None)
if ativo is None or passivo is None:
return []
- diferenca = ativo.saldo_atual + passivo.saldo_atual # passivo já vem negativo no relatório
+ # Questor: o Passivo já vem negativo no relatório, então fechar é somar
+ # a zero. Contabit: os dois vêm positivos (saldo pela natureza).
+ saldo_passivo = passivo.saldo_atual if atual.leiaute == LEIAUTE_CONTABIT else -passivo.saldo_atual
+ diferenca = ativo.saldo_atual - saldo_passivo
if diferenca == 0:
return []
return [
@@ -84,7 +107,7 @@ def regra_balanceamento_ativo_passivo(atual: ResultadoExtracao, historico: list[
titulo="Ativo não bate com Passivo",
mensagem=(
f"Saldo do Ativo ({_moeda(ativo.saldo_atual)}) não coincide com o do Passivo "
- f"({_moeda(-passivo.saldo_atual)}) — diferença de {_moeda(abs(diferenca))}."
+ f"({_moeda(saldo_passivo)}) — diferença de {_moeda(abs(diferenca))}."
),
valor_referencia=diferenca,
)
@@ -114,8 +137,9 @@ def regra_debito_credito_divergente(atual: ResultadoExtracao, historico: list[Sn
def regra_saldo_negativo_caixa(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
achados = []
+ prefixo = PREFIXO_CAIXA_CONTABIT if atual.leiaute == LEIAUTE_CONTABIT else PREFIXO_CAIXA_QUESTOR
for conta in atual.contas:
- if conta.codigo.startswith("1.01.01.001") and conta.saldo_atual < 0:
+ if conta.codigo.startswith(prefixo) and conta.saldo_atual < 0:
achados.append(
AchadoDetectado(
regra="saldo_negativo_caixa",
@@ -204,7 +228,42 @@ def _indices_descendentes_de_conta_redutora(contas: list[LinhaBalanceteExtraida]
return descendentes
+def _saldo_sinal_invertido_contabit(atual: ResultadoExtracao) -> list[AchadoDetectado]:
+ """No Contabit o saldo já sai pela natureza da própria conta (Passivo e
+ redutoras positivos), então saldo contrário à natureza é simplesmente
+ saldo negativo, em qualquer grupo. A natureza (devedora/credora) só
+ importa pro texto: grupo 1 é devedor, grupo 2 credor, invertido quando a
+ conta é redutora ou descende de uma (mesma pilha de níveis do Questor).
+
+ Premissa ainda não confirmada com arquivo real: o Contabit imprimir o
+ saldo contrário com "-". Se imprimir de outro jeito, a regra só deixa de
+ disparar (sem falso positivo)."""
+ achados = []
+ descendentes_de_redutora = _indices_descendentes_de_conta_redutora(atual.contas)
+ for i, conta in enumerate(atual.contas):
+ if conta.tipo != "A" or conta.saldo_atual >= 0 or not conta.codigo.startswith(("1.", "2.")):
+ continue
+ eh_ativo = conta.codigo.startswith("1.")
+ invertida = _eh_conta_redutora(conta) or i in descendentes_de_redutora
+ natureza_devedora = eh_ativo != invertida
+ grupo = "Ativo" if eh_ativo else "Passivo"
+ saldo = "credor" if natureza_devedora else "devedor"
+ achados.append(
+ AchadoDetectado(
+ regra="saldo_sinal_invertido",
+ severidade=SEVERIDADE_MEDIA,
+ titulo=f"Conta do {grupo} com saldo {saldo}",
+ mensagem=f'Conta do {grupo} "{conta.descricao}" ({conta.codigo}) está com saldo {saldo} de {_moeda(-conta.saldo_atual)}.',
+ codigo_conta=conta.codigo,
+ valor_referencia=conta.saldo_atual,
+ )
+ )
+ return achados
+
+
def regra_saldo_sinal_invertido(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
+ if atual.leiaute == LEIAUTE_CONTABIT:
+ return _saldo_sinal_invertido_contabit(atual)
achados = []
descendentes_de_redutora = _indices_descendentes_de_conta_redutora(atual.contas)
for i, conta in enumerate(atual.contas):
@@ -238,13 +297,16 @@ def regra_saldo_sinal_invertido(atual: ResultadoExtracao, historico: list[Snapsh
def regra_lucro_balancete_diverge_dre(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
"""O resultado do exercício precisa ser o mesmo número nas duas
demonstrações — só que, por convenção, o Passivo/PL vem com o sinal
- invertido no relatório (ver `regra_balanceamento_ativo_passivo`), então
- o saldo da linha do Balancete precisa ser negado antes de comparar com a
- última linha da DRE."""
- conta_lucro = next((c for c in atual.contas if c.codigo == CODIGO_LUCRO_PREJUIZO_EXERCICIO), None)
+ invertido no relatório do Questor (ver `regra_balanceamento_ativo_passivo`),
+ então o saldo da linha do Balancete precisa ser negado antes de comparar
+ com a última linha da DRE. No Contabit o saldo já sai pela natureza
+ (lucro positivo), sem negar."""
+ contabit = atual.leiaute == LEIAUTE_CONTABIT
+ codigo_lucro = CODIGO_LUCRO_PREJUIZO_EXERCICIO_CONTABIT if contabit else CODIGO_LUCRO_PREJUIZO_EXERCICIO
+ conta_lucro = next((c for c in atual.contas if c.codigo == codigo_lucro), None)
if conta_lucro is None or not atual.linhas_dre:
return []
- lucro_balancete = -conta_lucro.saldo_atual
+ lucro_balancete = conta_lucro.saldo_atual if contabit else -conta_lucro.saldo_atual
lucro_dre = atual.linhas_dre[-1].valor
diferenca = lucro_balancete - lucro_dre
if diferenca == 0:
@@ -308,7 +370,7 @@ def regra_variacao_atipica_dre(atual: ResultadoExtracao, historico: list[Snapsho
AchadoDetectado(
regra="variacao_atipica_dre",
severidade=SEVERIDADE_BAIXA,
- titulo="Variação atípica na DRE",
+ titulo=TITULO_VARIACAO_ATIPICA_DRE,
mensagem=(
f'Linha "{linha.descricao}" da DRE foi de {mes_anterior.percentual:.2f}% da Receita Bruta em '
f"{rotulo_anterior} para {mes_atual.percentual:.2f}% em {rotulo_atual} "
diff --git a/portal_api/migrations/0080_contabil_apuracao_leiaute.py b/portal_api/migrations/0080_contabil_apuracao_leiaute.py
new file mode 100644
index 0000000..cd9ba42
--- /dev/null
+++ b/portal_api/migrations/0080_contabil_apuracao_leiaute.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.7 on 2026-09-23 18:05
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('portal_api', '0079_contabil_ordering_determinista'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='contabilapuracao',
+ name='leiaute',
+ field=models.CharField(choices=[('questor', 'Questor'), ('contabit', 'Contabit')], db_default='questor', max_length=20, verbose_name='Modelo do arquivo'),
+ ),
+ migrations.AlterField(
+ model_name='contabilconta',
+ name='conta_numero',
+ field=models.IntegerField(blank=True, null=True, verbose_name='Número da conta'),
+ ),
+ ]
diff --git a/portal_api/migrations/0081_contabil_titulo_variacao_analise_vertical.py b/portal_api/migrations/0081_contabil_titulo_variacao_analise_vertical.py
new file mode 100644
index 0000000..b00054f
--- /dev/null
+++ b/portal_api/migrations/0081_contabil_titulo_variacao_analise_vertical.py
@@ -0,0 +1,32 @@
+"""Renomeia o título dos apontamentos de `variacao_atipica_dre` já gravados
+para "Variação atípica na DRE - Análise Vertical" (pedido do usuário). Só o
+`titulo`, que é um texto fixo da regra; a `mensagem` (que tem números
+interpolados) não é tocada. Texto duplicado aqui de propósito: migração não
+importa código de aplicação."""
+
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.db.migrations.state import StateApps
+
+TITULO_ANTIGO = "Variação atípica na DRE"
+TITULO_NOVO = "Variação atípica na DRE - Análise Vertical"
+
+
+def renomeia(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ ContabilAchado = apps.get_model("portal_api", "ContabilAchado")
+ ContabilAchado.objects.filter(regra="variacao_atipica_dre", titulo=TITULO_ANTIGO).update(titulo=TITULO_NOVO)
+
+
+def desfaz(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ ContabilAchado = apps.get_model("portal_api", "ContabilAchado")
+ ContabilAchado.objects.filter(regra="variacao_atipica_dre", titulo=TITULO_NOVO).update(titulo=TITULO_ANTIGO)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("portal_api", "0080_contabil_apuracao_leiaute"),
+ ]
+
+ operations = [
+ migrations.RunPython(renomeia, desfaz),
+ ]
diff --git a/portal_api/models.py b/portal_api/models.py
index 183600b..80cc050 100644
--- a/portal_api/models.py
+++ b/portal_api/models.py
@@ -2053,6 +2053,28 @@ class ContabilApuracao(models.Model):
# este campo só pra mostrar um aviso ao lado do nome da empresa, pedindo
# atenção a esse risco, não pra bloquear nada.
fonte_pdf_atipica = models.BooleanField("PDF de fonte atípica (risco de nomenclatura)", default=False)
+ # Sistema que gerou o PDF, detectado pelo parser (nunca informado no
+ # upload) — ver `dashboard_contabil.modelos.LEIAUTE_*`. Muda os códigos
+ # fixos das regras, a convenção de sinal e se a apuração mostra
+ # indicadores (`tem_indicadores`).
+ LEIAUTE_QUESTOR = "questor"
+ LEIAUTE_CONTABIT = "contabit"
+ LEIAUTE_CHOICES = [
+ (LEIAUTE_QUESTOR, "Questor"),
+ (LEIAUTE_CONTABIT, "Contabit"),
+ ]
+ # `db_default` (não só `default`): o banco é compartilhado com a produção,
+ # e um processo ainda rodando o código anterior a este campo não o envia
+ # no INSERT — o próprio Postgres preenche.
+ leiaute = models.CharField("Modelo do arquivo", max_length=20, choices=LEIAUTE_CHOICES, db_default=LEIAUTE_QUESTOR)
+
+ @property
+ def tem_indicadores(self) -> bool:
+ """Empresas do Contabit não usam indicadores por enquanto (decisão
+ explícita do usuário): os indicadores cadastrados são calibrados no
+ plano de contas do Questor e sairiam errados. A seção some da aba
+ "Dashboard", do relatório e do PDF do Resumo."""
+ return self.leiaute != self.LEIAUTE_CONTABIT
class Meta:
verbose_name = "Apuração do Relatório Contábil"
@@ -2105,7 +2127,8 @@ class ContabilConta(models.Model):
# criação, mesmo papel de ContabilLinhaDre.ordem) e é o que garante que
# a árvore do Balancete (ver dashboard-contabil.js) não misture grupos.
ordem = models.IntegerField("Ordem", default=0)
- conta_numero = models.IntegerField("Número da conta")
+ # Nulo só no leiaute Contabit, onde a conta sintética não tem número.
+ conta_numero = models.IntegerField("Número da conta", null=True, blank=True)
codigo = models.CharField("Classificação", max_length=60)
descricao = models.CharField("Descrição", max_length=255)
tipo = models.CharField("Tipo", max_length=1) # "S" sintética | "A" analítica
diff --git a/portal_api/serializers.py b/portal_api/serializers.py
index 821692c..18b0f96 100644
--- a/portal_api/serializers.py
+++ b/portal_api/serializers.py
@@ -2493,6 +2493,14 @@ class ContabilAchadoAjusteSerializer(serializers.Serializer):
observacao_contador = serializers.CharField(allow_blank=False)
+class ContabilAchadoValidarSerializer(serializers.Serializer):
+ """Corpo de `ContabilAchadoViewSet.validar()` — só para variação atípica
+ da Análise Vertical, que é tratada sem justificativa escrita (o texto
+ dessas variações mora em `ContabilObservacao` da linha, ver a view)."""
+
+ validado = serializers.BooleanField()
+
+
class ContabilAchadoOcultoSerializer(serializers.Serializer):
"""Corpo de `ContabilAchadoViewSet.alternar_oculto()` — separado de
`ContabilAchadoAjusteSerializer` porque ocultar do relatório não deve
@@ -2734,6 +2742,7 @@ class ContabilApuracaoListSerializer(serializers.ModelSerializer):
"concluida_em",
"total_achados_pendentes",
"fonte_pdf_atipica",
+ "leiaute",
"reprocessamentos",
]
@@ -2747,6 +2756,7 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
linhas_dre = ContabilLinhaDreSerializer(many=True, read_only=True)
linhas_analise_vertical = ContabilLinhaAnaliseVerticalSerializer(many=True, read_only=True)
achados = ContabilAchadoSerializer(many=True, read_only=True)
+ tem_indicadores = serializers.BooleanField(read_only=True)
class Meta:
model = ContabilApuracao
@@ -2764,6 +2774,8 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
"concluida_em",
"resumo_fechamento",
"fonte_pdf_atipica",
+ "leiaute",
+ "tem_indicadores",
"contas",
"linhas_dre",
"analise_vertical_meses",
diff --git a/portal_api/views.py b/portal_api/views.py
index b1c9f8a..a558ece 100644
--- a/portal_api/views.py
+++ b/portal_api/views.py
@@ -49,6 +49,7 @@ from .dashboard_contabil import resumo_pdf as dashboard_contabil_resumo_pdf
from .templatetags import contabil_extras as dashboard_contabil_extras
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
from .dashboard_contabil.modelos import SnapshotHistorico as ContabilSnapshotHistorico
+from .dashboard_contabil.parser import CodigoEmpresaAusenteError as ContabilCodigoEmpresaAusenteError
from .dashboard_contabil.parser import ExtracaoInvalidaError as ContabilExtracaoInvalidaError
from .models import (
AcessoGeral,
@@ -133,6 +134,7 @@ from .serializers import (
CompromissoAgendaSerializer,
ContabilAchadoAjusteSerializer,
ContabilAchadoOcultoSerializer,
+ ContabilAchadoValidarSerializer,
ContabilAchadoSerializer,
ContabilApuracaoCreateSerializer,
ContabilApuracaoDetailSerializer,
@@ -3730,10 +3732,16 @@ def _contabil_dados_resumo(apuracao: ContabilApuracao) -> dict[str, Any]:
(`ContabilApuracaoViewSet.resumo_pdf()`, ver `resumo_pdf.py`). Devolve
`observacoes_visiveis` **sem** separar por `alvo_tipo` nem calcular
`ancora` — isso é específico de cada consumidor (o relatório HTML separa
- e pendura `ancora` pra permitir o clique-até-a-conta; o PDF só lista)."""
- dados = _contabil_coleta_dados_indicadores(apuracao)
- valores_indicadores = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados)
- cards = _contabil_monta_cards_indicadores(apuracao, valores_indicadores)
+ e pendura `ancora` pra permitir o clique-até-a-conta; o PDF só lista).
+ Apuração sem indicadores (`tem_indicadores`, leiaute Contabit) devolve
+ `indicadores_grupos` vazio, sem nem calcular."""
+ indicadores_grupos: list[dict[str, Any]] = []
+ if apuracao.tem_indicadores:
+ dados = _contabil_coleta_dados_indicadores(apuracao)
+ valores_indicadores = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados)
+ indicadores_grupos = _contabil_agrupa_indicadores_cards(
+ _contabil_monta_cards_indicadores(apuracao, valores_indicadores)
+ )
achados = list(apuracao.achados.select_related("conta", "tratado_por").order_by("severidade", "id"))
# Observações que o contador marcou pra mostrar ao cliente, já no recorte
# de vigência desta competência (ver `ContabilObservacao`) — inclui as
@@ -3747,7 +3755,7 @@ def _contabil_dados_resumo(apuracao: ContabilApuracao) -> dict[str, Any]:
if observacao.mostrar_ao_cliente
]
return {
- "indicadores_grupos": _contabil_agrupa_indicadores_cards(cards),
+ "indicadores_grupos": indicadores_grupos,
"observacoes_visiveis": observacoes_visiveis,
# Cards que o contador escondeu na aba "Dashboard" da tela de revisão
# (ver `indicadores_ocultos()`) já ficam de fora de `cards` acima;
@@ -4176,18 +4184,10 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
try:
resultado = dashboard_contabil_pipeline.processa_apuracao(
- io.BytesIO(conteudo), _contabil_monta_historico
- )
- except ContabilExtracaoInvalidaError:
- return Response(
- {
- "detail": (
- "O arquivo não está no formato esperado (Balancete + DRE do Questor). "
- "Contate o setor de Inovação."
- )
- },
- status=status.HTTP_400_BAD_REQUEST,
+ io.BytesIO(conteudo), _contabil_monta_historico, arquivo.name
)
+ except ContabilExtracaoInvalidaError as exc:
+ return _contabil_resposta_extracao_invalida(exc)
cabecalho = resultado.extracao.cabecalho
competencia = cabecalho.periodo_fim.replace(day=1)
@@ -4211,6 +4211,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
criado_por=request.user,
analise_vertical_meses=resultado.extracao.meses_analise_vertical,
fonte_pdf_atipica=resultado.extracao.fonte_pdf_atipica,
+ leiaute=resultado.extracao.leiaute,
)
ContabilConta.objects.bulk_create(
[
@@ -4339,18 +4340,10 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
try:
resultado = dashboard_contabil_pipeline.processa_apuracao(
- io.BytesIO(conteudo), _contabil_monta_historico
- )
- except ContabilExtracaoInvalidaError:
- return Response(
- {
- "detail": (
- "O arquivo não está no formato esperado (Balancete + DRE do Questor). "
- "Contate o setor de Inovação."
- )
- },
- status=status.HTTP_400_BAD_REQUEST,
+ io.BytesIO(conteudo), _contabil_monta_historico, arquivo.name
)
+ except ContabilExtracaoInvalidaError as exc:
+ return _contabil_resposta_extracao_invalida(exc)
cabecalho = resultado.extracao.cabecalho
nova_competencia = cabecalho.periodo_fim.replace(day=1)
@@ -4358,8 +4351,9 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
return Response(
{
"detail": (
- f"O arquivo anexado é de {cabecalho.nome_empresa} ({nova_competencia:%m/%Y}), "
- f"diferente desta análise ({apuracao.nome_empresa} — {apuracao.competencia:%m/%Y}). "
+ f"O arquivo anexado é de {cabecalho.codigo_empresa} - {cabecalho.nome_empresa} "
+ f"({nova_competencia:%m/%Y}), diferente desta análise ({apuracao.codigo_empresa} - "
+ f"{apuracao.nome_empresa} — {apuracao.competencia:%m/%Y}). "
"Reprocessar exige o mesmo período/empresa — pra outra competência, crie uma nova análise."
)
},
@@ -4376,6 +4370,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
apuracao.periodo_fim = cabecalho.periodo_fim
apuracao.analise_vertical_meses = resultado.extracao.meses_analise_vertical
apuracao.fonte_pdf_atipica = resultado.extracao.fonte_pdf_atipica
+ apuracao.leiaute = resultado.extracao.leiaute
apuracao.arquivo = ContentFile(conteudo, name=arquivo.name)
apuracao.save()
novo_arquivo_salvo = True
@@ -4565,6 +4560,18 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
de verdade — não têm mais tratamento especial aqui, ver CLAUDE.md do
pacote."""
apuracao = self.get_object()
+ if not apuracao.tem_indicadores:
+ # Leiaute Contabit: a aba "Dashboard" nem mostra a seção (ver
+ # `ContabilApuracao.tem_indicadores`), isto só garante que uma
+ # chamada direta também não calcule nada.
+ return Response(
+ {
+ "indicadores": {},
+ "indicadores_ocultos": apuracao.indicadores_ocultos,
+ "indicadores_selecionados": apuracao.indicadores_selecionados,
+ "metadados": {},
+ }
+ )
dados = _contabil_coleta_dados_indicadores(apuracao)
selecionados = apuracao.indicadores_selecionados
valores = _contabil_calcula_indicadores_personalizados(dados, selecionados)
@@ -4771,6 +4778,20 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
return response
+def _contabil_resposta_extracao_invalida(exc: ContabilExtracaoInvalidaError) -> Response:
+ """400 de `create()`/`reprocessar()` quando o PDF não pôde ser lido.
+ Código da empresa ausente no nome do arquivo (leiaute Contabit) tem
+ mensagem própria: o PDF está certo, só o nome do arquivo precisa mudar."""
+ if isinstance(exc, ContabilCodigoEmpresaAusenteError):
+ detalhe = str(exc)
+ else:
+ detalhe = (
+ "O arquivo não está no formato esperado (Balancete + DRE do Questor ou do Contabit). "
+ "Contate o setor de Inovação."
+ )
+ return Response({"detail": detalhe}, status=status.HTTP_400_BAD_REQUEST)
+
+
def _contabil_garante_em_revisao(apuracao: ContabilApuracao) -> None:
if apuracao.status == ContabilApuracao.STATUS_CONCLUIDA:
raise ValidationError(
@@ -5032,6 +5053,33 @@ class ContabilAchadoViewSet(viewsets.ModelViewSet):
achado.save(update_fields=["oculto_no_relatorio"])
return Response(ContabilAchadoSerializer(achado).data)
+ @action(detail=True, methods=["post"])
+ def validar(self, request: Request, pk: str | None = None) -> Response:
+ """Botão "Validar" das variações atípicas da Análise Vertical (pedido
+ explícito do usuário): `validado=true` trata o apontamento
+ (`status="tratado"`) **sem** exigir `observacao_contador`, e
+ `validado=false` devolve a "pendente". O texto sobre essas variações
+ não vai no apontamento: é uma `ContabilObservacao` da própria linha da
+ Análise Vertical, com histórico entre competências e o "mostrar ao
+ cliente" de sempre. As demais regras continuam só pelo PATCH com
+ justificativa obrigatória."""
+ achado = self.get_object()
+ _contabil_garante_em_revisao(achado.apuracao)
+ if achado.regra != "variacao_atipica_dre":
+ raise ValidationError({"detail": "Só variações atípicas da Análise Vertical são validadas por aqui."})
+ entrada = ContabilAchadoValidarSerializer(data=request.data)
+ entrada.is_valid(raise_exception=True)
+ if entrada.validated_data["validado"]:
+ achado.status = ContabilAchado.STATUS_TRATADO
+ achado.tratado_por = request.user
+ achado.tratado_em = timezone.now()
+ else:
+ achado.status = ContabilAchado.STATUS_PENDENTE
+ achado.tratado_por = None
+ achado.tratado_em = None
+ achado.save(update_fields=["status", "tratado_por", "tratado_em"])
+ return Response(ContabilAchadoSerializer(achado).data)
+
def _gera_chave_indicador_contabil(nome: str) -> str:
"""Deriva a `chave` de um `IndicadorContabilDefinicao` a partir do
diff --git a/static/css/dashboard-contabil.css b/static/css/dashboard-contabil.css
index 8beea1c..6f1ab4d 100644
--- a/static/css/dashboard-contabil.css
+++ b/static/css/dashboard-contabil.css
@@ -717,6 +717,98 @@
flex-wrap: wrap;
}
+/* Lista "Variação atípica na DRE - Análise Vertical" no fim da aba Análise
+ Vertical: mesmos cards da aba Observações, com a thread de observação da
+ linha abrindo numa linha própria do card (flex-basis 100%, o card já é
+ flex-wrap) e o botão de validar em `--teal`, a mesma cor do "validado"
+ das contas/linhas. */
+.dc-variacoes__dica {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ margin: 0 0 var(--space-3);
+}
+
+.dc-variacoes__lista {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.dc-achado-card__actions .dc-conta-observacao-btn {
+ align-self: center;
+}
+
+.dc-variacao--validada {
+ border-left-color: var(--teal);
+}
+
+.dc-variacao-validado-btn {
+ background: var(--teal);
+ border-color: var(--teal);
+}
+
+.dc-variacao__validado-por {
+ font-size: 0.8rem;
+ color: var(--teal);
+ margin-top: var(--space-2);
+}
+
+.dc-variacao__obs {
+ flex: 1 1 100%;
+ min-width: 0;
+}
+
+/* Pulso no card ao chegar pelo ícone da tabela (dcIrParaVariacao) — mesmo
+ `dcFocoPulse` das linhas, mas por fora do card (não `inset`). */
+@keyframes dcVariacaoFocoPulse {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0);
+ }
+ 50% {
+ box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.9);
+ }
+}
+
+.dc-variacao--foco {
+ animation: dcVariacaoFocoPulse 700ms ease-in-out 3;
+}
+
+/* Ícone de variação atípica na coluna de ações da Análise Vertical, antes do
+ "validado": dourado enquanto pendente, teal depois de validada. */
+.dc-variacao-icone-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: rgba(var(--gold-rgb), 0.16);
+ color: var(--gold);
+ cursor: pointer;
+}
+
+.dc-variacao-icone-btn:hover {
+ background: rgba(var(--gold-rgb), 0.28);
+}
+
+.dc-variacao-icone-btn--validada {
+ background: rgba(var(--teal-rgb), 0.16);
+ color: var(--teal);
+}
+
+.dc-variacao-icone-btn--validada:hover {
+ background: rgba(var(--teal-rgb), 0.28);
+}
+
+/* O tooltip deste ícone carrega a mensagem + até 3 observações. */
+.dc-hover-tooltip--largo .dc-hover-tooltip__texto {
+ max-width: 400px;
+ text-align: left;
+}
+
/* Expande/recolhe a conta usada no apontamento (achado.conta) — só
presente em achados vinculados a uma conta específica; regras gerais
(ex. balanceamento Ativo x Passivo) não têm esse toggle. */
diff --git a/static/js/dashboard-contabil.js b/static/js/dashboard-contabil.js
index 27b7cde..1b308db 100644
--- a/static/js/dashboard-contabil.js
+++ b/static/js/dashboard-contabil.js
@@ -22,7 +22,7 @@ const PID_DC_REGRAS = [
{ chave: "conta_transitoria_com_saldo", label: "Contas Transitórias" },
{ chave: "conta_deveria_zerar", label: "Contas que Deveriam Zerar" },
{ chave: "descricao_generica", label: "Descrição Genérica" },
- { chave: "variacao_atipica_dre", label: "Variação Atípica na DRE" },
+ { chave: "variacao_atipica_dre", label: "Variação atípica na DRE - Análise Vertical" },
{ chave: "item_removido_reprocessamento", label: "Contas e Linhas Removidas" },
];
@@ -46,10 +46,8 @@ const PID_DC_GRUPOS = [
label: "Contas Atípicas",
regras: ["conta_transitoria_com_saldo", "conta_deveria_zerar", "descricao_generica"],
},
- {
- label: "Variações e Indicadores",
- regras: ["variacao_atipica_dre"],
- },
+ // "Variações e Indicadores" (variacao_atipica_dre) saiu daqui: essas
+ // variações só aparecem no fim da aba Análise Vertical.
// `somenteComAchados`: só aparece na apuração em que algo foi de fato
// removido — um card fixo em "0" em toda análise nunca reprocessada seria
// ruído, diferente das regras acima, cujo "0" é a informação de que aquela
@@ -364,6 +362,13 @@ async function pidAtualizarAchadoContabil(id, status, observacaoContador) {
});
}
+// Só variação atípica da Análise Vertical: validar trata o apontamento sem
+// justificativa escrita (o texto vai na observação da linha), desvalidar
+// volta a "pendente" — ver ContabilAchadoViewSet.validar().
+async function pidValidarAchadoContabil(id, validado) {
+ return pidApiRequest(`/contabil-achados/${id}/validar/`, { method: "POST", body: { validado } });
+}
+
// ---- Aba "Dashboard" (pré-visualização do relatório "Gerar Dashboard") ----
async function pidBuscarIndicadoresContabil(id) {
@@ -1098,7 +1103,7 @@ document.addEventListener("DOMContentLoaded", async () => {
dcDreDestaque = dcUltimaLevaVisivel(apuracao.linhas_dre, (linha) => Math.max(0, linha.nivel), dcDreColapsadas);
dcContasExpandidos = new Set();
dcDreExpandidos = new Set();
- dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null };
+ dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null, variacao: null };
dcObsEditandoId = null;
dcAchadosContaExpandida = new Set();
dcIndicadoresAtual = null;
@@ -1137,8 +1142,16 @@ document.addEventListener("DOMContentLoaded", async () => {
const PID_DC_SEVERIDADE_ORDEM = { alta: 0, media: 1, baixa: 2 };
+ // Variação atípica da Análise Vertical não entra na aba Observações (pedido
+ // explícito do usuário): já tem lista própria, com validação, no fim da aba
+ // Análise Vertical (renderVariacoesAnaliseVertical). Vale pra lista, donut
+ // e cards de categoria.
+ function dcAchadosDaAbaObservacoes() {
+ return apuracaoAtual.achados.filter((achado) => achado.regra !== "variacao_atipica_dre");
+ }
+
function achadosFiltrados() {
- return apuracaoAtual.achados
+ return dcAchadosDaAbaObservacoes()
.filter((achado) => {
if (filtroSeveridade !== "todas" && achado.severidade !== filtroSeveridade) return false;
if (filtroStatusAchado === "pendente" && achado.status !== "pendente") return false;
@@ -1160,7 +1173,7 @@ document.addEventListener("DOMContentLoaded", async () => {
// visão geral estável, mesmo espírito do "Distribuição das Análises" do
// sistema externo que inspirou esta seção.
function renderAchadosResumo() {
- const achados = apuracaoAtual.achados;
+ const achados = dcAchadosDaAbaObservacoes();
const total = achados.length;
// ---- Donut por severidade ----
@@ -1321,8 +1334,7 @@ document.addEventListener("DOMContentLoaded", async () => {
+ ${podeEditar ? `` : ""}
`;
lista.appendChild(card);
});
@@ -1790,8 +1802,8 @@ document.addEventListener("DOMContentLoaded", async () => {
// badges abaixo. `gatilhoHtml` é o ícone (já pronto, com sua própria
// classe/cor); `texto` vai só no `` do tooltip (nunca em `title`,
// que voltaria a mostrar o balão nativo do navegador em cima do custom).
- function pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto) {
- return `
+ function pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto, classeExtra = "") {
+ return `${gatilhoHtml}${pidDcEscapeHtml(texto)}`;
@@ -1870,7 +1882,12 @@ document.addEventListener("DOMContentLoaded", async () => {
// Qual linha está com o painel de observações aberto, por tabela (no
// máximo uma por tabela, mesmo espírito dos antigos dcContaObsEditId/
// dcDreObsEditId/dcAvObsEditId que este objeto substituiu).
- let dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null };
+ // `variacao` é o painel aberto dentro de um card da lista "Variações
+ // atípicas" (fim da aba Análise Vertical): mesma observação da linha da
+ // Análise Vertical, só exibida em outro lugar — por isso abrir um fecha o
+ // outro (ver dcTrataCliqueObservacao), senão a mesma thread existiria duas
+ // vezes na tela com os mesmos ids de campo.
+ let dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null, variacao: null };
// Observação (id) em edição inline dentro do painel — só uma por vez, e só
// enquanto ela for da competência aberta (histórica nunca é editável).
let dcObsEditandoId = null;
@@ -2117,6 +2134,13 @@ document.addEventListener("DOMContentLoaded", async () => {
// embaixo. Uma observação nunca é sobrescrita — escrever de novo cria mais
// um item na thread.
function dcObsPainelHtml(tipo, alvoId, observacoes, colspan, concluida) {
+ return `
`;
+ }
+
+ // `escopo` separa os ids dos campos e a chave de dcObsPainelAberto quando
+ // a mesma thread é aberta fora da tabela (card de variação: escopo
+ // "variacao"); `tipo` continua sendo o alvo_tipo enviado à API.
+ function dcObsPainelConteudoHtml(tipo, alvoId, observacoes, concluida, escopo = tipo) {
const thread = observacoes.length
? observacoes.map((obs) => dcObsItemHtml(obs, concluida)).join("")
: '
Anexe o balancete + DRE em PDF (modelo Questor) de uma empresa — a ferramenta extrai as contas, roda um conjunto de checagens automáticas e apresenta as observações para análise antes da conclusão.
+
Anexe o balancete + DRE em PDF (modelo Questor ou Contabit) de uma empresa — a ferramenta extrai as contas, roda um conjunto de checagens automáticas e apresenta as observações para análise antes da conclusão. No modelo Contabit, o nome do arquivo precisa começar pelo código da empresa (ex.: "1512 - Balancete 082026.pdf").