Implementação do relatório Contábil - Contabit

This commit is contained in:
Gabriel 2026-09-23 16:07:46 -03:00
parent e4d95266ef
commit df1f4e6a1f
20 changed files with 1073 additions and 129 deletions

View File

@ -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",

View File

@ -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`.

View File

@ -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.

View File

@ -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`, `<title>`/`<h1>`/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 `<circle r="15.9155">` — circunferência ≈ 100, então `stroke-dasharray`/`stroke-dashoffset` já funcionam em unidades de percentual sem `pathLength`. Cada segmento é um `<circle>` 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.

View File

@ -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).

View File

@ -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))

View File

@ -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

View File

@ -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()

View File

@ -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<codigo>\d+(?:\.\d+)*)\s+(?P<descricao>.+)$")
_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,
)

View File

@ -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)

View File

@ -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} "

View File

@ -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'),
),
]

View File

@ -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),
]

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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. */

View File

@ -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 () => {
</div>
<div class="dc-achado-card__actions">
${temAlvoNaTabela ? `<button type="button" class="btn-outline" data-dc-achado-ir-para="${achado.id}" title="Ir até a linha referida por este apontamento">${PID_DC_ICON_LOCALIZAR} Ver na tabela</button>` : ""}
${podeEditar ? `<button type="button" class="btn-outline" data-dc-achado-editar="${achado.id}">Revisar</button>` : ""}
</div>
${podeEditar ? `<button type="button" class="btn-outline" data-dc-achado-editar="${achado.id}">Revisar</button>` : ""} </div>
`;
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 `<span>` 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 `<span class="dc-hover-tooltip">
function pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto, classeExtra = "") {
return `<span class="dc-hover-tooltip${classeExtra ? ` ${classeExtra}` : ""}">
<span class="dc-hover-tooltip__gatilho ${gatilhoClasse}" tabindex="0" aria-label="${pidDcEscapeHtml(texto)}">${gatilhoHtml}</span>
<span class="dc-hover-tooltip__texto" role="tooltip">${pidDcEscapeHtml(texto)}</span>
</span>`;
@ -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 `<td colspan="${colspan}">${dcObsPainelConteudoHtml(tipo, alvoId, observacoes, concluida)}</td>`;
}
// `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("")
: '<p class="dc-obs-thread__vazio">Nenhuma observação registrada até agora.</p>';
@ -2124,28 +2148,26 @@ document.addEventListener("DOMContentLoaded", async () => {
? ""
: `<div class="dc-obs-nova">
<div class="modal-field">
<textarea id="dc-obs-nova-texto-${tipo}-${alvoId}" rows="3" placeholder="Escreva uma observação nova..."></textarea>
<textarea id="dc-obs-nova-texto-${escopo}-${alvoId}" rows="3" placeholder="Escreva uma observação nova..."></textarea>
</div>
<label class="modal-checkbox">
<input type="checkbox" id="dc-obs-nova-mostrar-${tipo}-${alvoId}" />
<input type="checkbox" id="dc-obs-nova-mostrar-${escopo}-${alvoId}" />
Mostrar esta observação ao cliente no relatório
</label>
<p class="modal-error" id="dc-obs-nova-erro-${tipo}-${alvoId}"></p>
<p class="modal-error" id="dc-obs-nova-erro-${escopo}-${alvoId}"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" data-dc-obs-fechar="${tipo}">Fechar</button>
<button type="button" class="btn-solid" data-dc-obs-criar="${tipo}:${alvoId}">Salvar observação</button>
<button type="button" class="btn-outline" data-dc-obs-fechar="${escopo}">Fechar</button>
<button type="button" class="btn-solid" data-dc-obs-criar="${tipo}:${alvoId}:${escopo}">Salvar observação</button>
</div>
</div>`;
const rodape = concluida
? '<p class="dc-obs-thread__vazio">Análise concluída, histórico somente leitura.</p>'
: "";
return `
<td colspan="${colspan}">
<div class="dc-obs-edit">
<div class="dc-obs-thread">${thread}</div>
${nova}${rodape}
</div>
</td>
<div class="dc-obs-edit">
<div class="dc-obs-thread">${thread}</div>
${nova}${rodape}
</div>
`;
}
@ -2159,6 +2181,8 @@ document.addEventListener("DOMContentLoaded", async () => {
const [tipoBtn, idTexto] = abrirBtn.getAttribute("data-dc-obs-abrir").split(":");
const id = Number(idTexto);
dcObsPainelAberto[tipoBtn] = dcObsPainelAberto[tipoBtn] === id ? null : id;
if (tipoBtn === "variacao") dcObsPainelAberto.analise_vertical = null;
if (tipoBtn === "analise_vertical") dcObsPainelAberto.variacao = null;
dcObsEditandoId = null;
dcRenderTabelaDoTipo(tipo);
return true;
@ -2174,11 +2198,12 @@ document.addEventListener("DOMContentLoaded", async () => {
const criarBtn = event.target.closest("[data-dc-obs-criar]");
if (criarBtn) {
const [alvoTipo, idTexto] = criarBtn.getAttribute("data-dc-obs-criar").split(":");
const [alvoTipo, idTexto, escopoTexto] = criarBtn.getAttribute("data-dc-obs-criar").split(":");
const alvoId = Number(idTexto);
const erroEl = document.getElementById(`dc-obs-nova-erro-${alvoTipo}-${alvoId}`);
const texto = document.getElementById(`dc-obs-nova-texto-${alvoTipo}-${alvoId}`).value.trim();
const mostrar = document.getElementById(`dc-obs-nova-mostrar-${alvoTipo}-${alvoId}`).checked;
const escopo = escopoTexto || alvoTipo;
const erroEl = document.getElementById(`dc-obs-nova-erro-${escopo}-${alvoId}`);
const texto = document.getElementById(`dc-obs-nova-texto-${escopo}-${alvoId}`).value.trim();
const mostrar = document.getElementById(`dc-obs-nova-mostrar-${escopo}-${alvoId}`).checked;
erroEl.textContent = "";
if (!texto) {
erroEl.textContent = "Escreva a observação antes de salvar.";
@ -2368,7 +2393,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (dcContasDestaque.has(conta.id)) tr.classList.add("dc-conta-row--destaque");
if (dcFocoLinha && dcFocoLinha.tipo === "conta" && dcFocoLinha.id === conta.id) tr.classList.add("dc-conta-row--foco");
tr.innerHTML = `
<td>${pidDcEscapeHtml(conta.conta_numero)}</td>
<td>${pidDcEscapeHtml(conta.conta_numero ?? "")}</td>
<td>${pidDcEscapeHtml(conta.codigo)}</td>
<td style="padding-left: calc(var(--space-4) + ${nivel * 18}px)">
<span class="dc-conta-desc-cell">${toggle}<span>${pidDcEscapeHtml(conta.descricao)}</span></span>
@ -2604,6 +2629,13 @@ document.addEventListener("DOMContentLoaded", async () => {
const niveis = linhas.map(nivelFn);
const temFilhos = linhas.map((_, i) => i + 1 < linhas.length && niveis[i + 1] > niveis[i]);
const variacoesPorLinha = new Map();
(apuracaoAtual.achados || []).forEach((achado) => {
if (achado.regra === "variacao_atipica_dre" && achado.linha_analise_vertical != null) {
variacoesPorLinha.set(achado.linha_analise_vertical, achado);
}
});
const pilhaColapso = [];
linhas.forEach((linha, i) => {
const nivel = niveis[i];
@ -2647,6 +2679,7 @@ document.addEventListener("DOMContentLoaded", async () => {
${colunasMensais}
<td>
<div class="dc-obs-cell-actions">
${dcVariacaoIconeHtml(variacoesPorLinha.get(linha.id), observacoesDaLinha)}
<button type="button" class="dc-conta-validado-btn${validadoInfo.classe}" data-dc-av-validado="${linha.id}" title="${validadoInfo.title}" aria-label="${validadoInfo.label}" aria-pressed="${validadoInfo.pressed}" ${concluida ? "disabled" : ""}>
${PID_DC_VALIDADO_ICONE}
</button>
@ -2667,9 +2700,158 @@ document.addEventListener("DOMContentLoaded", async () => {
dcAtualizaBotaoArvore("analise-vertical", dcAvColapsadas, "da Análise Vertical");
renderAnaliseVerticalObsResumo();
renderVariacoesAnaliseVertical();
}
// Ícone de variação atípica na linha da Análise Vertical (pedido explícito
// do usuário): sinaliza na própria tabela que a linha teve variação
// relevante. No hover mostra a mensagem da variação, o status e as
// observações da linha; no clique leva ao card da variação no fim da aba,
// já com a thread de observação aberta (dcIrParaVariacao). Dourado enquanto
// pendente, teal depois de validada (mesma cor do "validado").
const PID_DC_VARIACAO_ICONE =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg>';
function dcVariacaoIconeHtml(achado, observacoes) {
if (!achado) return "";
const validado = achado.status === "tratado";
const partes = [
`${achado.titulo} (${validado ? "validada" : "pendente"})`,
achado.mensagem,
];
if (validado && achado.tratado_por_nome) {
partes.push(`Validada por ${achado.tratado_por_nome} em ${pidDcFormatData(achado.tratado_em)}.`);
}
if (observacoes.length) {
partes.push(`\nObservações (${observacoes.length}):`);
observacoes.slice(0, 3).forEach((obs) => {
const texto = obs.texto.length > 160 ? `${obs.texto.slice(0, 157)}...` : obs.texto;
partes.push(`• ${obs.criado_por_nome || "Usuário removido"}, ${pidDcFormatData(obs.criado_em)}: ${texto}`);
});
if (observacoes.length > 3) partes.push(`... e mais ${observacoes.length - 3}.`);
} else {
partes.push("\nNenhuma observação registrada.");
}
partes.push(validado ? "\nClique para ver ou comentar." : "\nClique para validar e comentar.");
const botao = `<button type="button" class="dc-variacao-icone-btn${validado ? " dc-variacao-icone-btn--validada" : ""}" data-dc-av-variacao="${achado.linha_analise_vertical}" aria-label="Ir para a variação atípica desta linha">${PID_DC_VARIACAO_ICONE}</button>`;
return pidDcHoverTooltipHtml(botao, "", partes.join("\n"), "dc-hover-tooltip--largo");
}
// Card da variação que acabou de receber o clique do ícone — destacado por
// um pulso curto, mesmo espírito de dcFocoLinha nas tabelas.
let dcVariacaoFoco = null;
function dcIrParaVariacao(linhaId) {
dcObsPainelAberto.variacao = linhaId;
dcObsPainelAberto.analise_vertical = null;
dcObsEditandoId = null;
dcVariacaoFoco = linhaId;
renderAnaliseVertical();
requestAnimationFrame(() => {
const card = document.querySelector(`[data-dc-variacao-linha="${linhaId}"]`);
if (card) card.scrollIntoView({ behavior: "smooth", block: "center" });
});
setTimeout(() => {
if (dcVariacaoFoco !== linhaId) return;
dcVariacaoFoco = null;
const card = document.querySelector(`[data-dc-variacao-linha="${linhaId}"]`);
if (card) card.classList.remove("dc-variacao--foco");
}, 2400);
}
// Botão "Validar" de uma variação atípica: trata o apontamento sem texto
// (o texto é a observação da linha), clicar de novo volta a "pendente".
function dcValidarVariacaoBtnHtml(achado) {
const validado = achado.status === "tratado";
const concluida = apuracaoAtual.status === "concluida";
const rotulo = validado ? "✓ Validado" : "Validar";
const titulo = validado ? "Clique para voltar a pendente" : "Confirmar que esta variação foi validada";
return `<button type="button" class="${validado ? "btn-solid dc-variacao-validado-btn" : "btn-outline"}" data-dc-variacao-validar="${achado.id}" title="${titulo}" ${concluida ? "disabled" : ""}>${rotulo}</button>`;
}
async function dcAlternarValidacaoVariacao(id, botao) {
const achado = apuracaoAtual.achados.find((a) => a.id === id);
if (!achado || botao.disabled) return;
botao.disabled = true;
try {
const atualizado = await pidValidarAchadoContabil(id, achado.status !== "tratado");
const indice = apuracaoAtual.achados.findIndex((a) => a.id === id);
apuracaoAtual.achados[indice] = atualizado;
// A tabela inteira, não só a lista: o ícone de variação da linha muda
// de cor junto (renderAnaliseVertical já redesenha a lista no fim).
renderAnaliseVertical();
await carregarLista();
} catch (e) {
botao.disabled = false;
await pidAlert(e.message);
}
}
// Lista "Variação atípica na DRE - Análise Vertical" no fim da aba (pedido
// explícito do usuário): um card por apontamento de variacao_atipica_dre,
// com a observação da própria linha (mesma thread da tabela, escopo
// "variacao") e o botão de validar.
function renderVariacoesAnaliseVertical() {
const lista = document.getElementById("dc-av-variacoes-list");
const vazio = document.getElementById("dc-av-variacoes-empty");
const variacoes = (apuracaoAtual.achados || []).filter((a) => a.regra === "variacao_atipica_dre");
const concluida = apuracaoAtual.status === "concluida";
document.getElementById("dc-av-variacoes-count").textContent = variacoes.length;
vazio.hidden = variacoes.length > 0;
lista.innerHTML = variacoes
.map((achado) => {
const linhaId = achado.linha_analise_vertical;
const linha = linhaId != null ? (apuracaoAtual.linhas_analise_vertical || []).find((l) => l.id === linhaId) : null;
const observacoes = linha ? dcObservacoesDe("analise_vertical", dcChaveObsLinha(linha)) : [];
const painelAberto = linha && dcObsPainelAberto.variacao === linhaId;
const validadoPor =
achado.status === "tratado" && achado.tratado_por_nome
? `<div class="dc-variacao__validado-por">Validado por ${pidDcEscapeHtml(achado.tratado_por_nome)} em ${pidDcFormatData(achado.tratado_em)}</div>`
: "";
return `
<div class="dc-achado-card dc-achado-card--${achado.severidade} dc-variacao${achado.status === "tratado" ? " dc-variacao--validada" : ""}${linhaId != null && dcVariacaoFoco === linhaId ? " dc-variacao--foco" : ""}" data-dc-variacao-linha="${linhaId ?? ""}">
<div class="dc-achado-card__main">
<div class="dc-achado-card__titulo">
<span class="dc-badge dc-badge--${achado.status}">${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status}</span>
${pidDcEscapeHtml(achado.titulo)}
</div>
<div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</div>
${validadoPor}
</div>
<div class="dc-achado-card__actions">
${linha ? `<button type="button" class="btn-outline" data-dc-achado-ir-para="${achado.id}" title="Ir até a linha referida por este apontamento">${PID_DC_ICON_LOCALIZAR} Ver na tabela</button>` : ""}
${linha ? dcObsBotaoHtml("variacao", linhaId, observacoes, false) : ""}
${dcValidarVariacaoBtnHtml(achado)}
</div>
${painelAberto ? `<div class="dc-variacao__obs">${dcObsPainelConteudoHtml("analise_vertical", linhaId, observacoes, concluida, "variacao")}</div>` : ""}
</div>
`;
})
.join("");
}
document.getElementById("dc-av-variacoes-list").addEventListener("click", async (event) => {
const validarBtn = event.target.closest("[data-dc-variacao-validar]");
if (validarBtn) {
await dcAlternarValidacaoVariacao(Number(validarBtn.getAttribute("data-dc-variacao-validar")), validarBtn);
return;
}
const irParaBtn = event.target.closest("[data-dc-achado-ir-para]");
if (irParaBtn) {
const achado = apuracaoAtual.achados.find((a) => a.id === Number(irParaBtn.getAttribute("data-dc-achado-ir-para")));
if (achado && achado.linha_analise_vertical != null) dcIrParaLinha("av", achado.linha_analise_vertical);
return;
}
await dcTrataCliqueObservacao(event, "analise_vertical");
});
document.getElementById("dc-av-body").addEventListener("click", async (event) => {
const variacaoBtn = event.target.closest("[data-dc-av-variacao]");
if (variacaoBtn) {
dcIrParaVariacao(Number(variacaoBtn.getAttribute("data-dc-av-variacao")));
return;
}
const toggleBtn = event.target.closest("[data-dc-av-toggle]");
if (toggleBtn) {
const id = Number(toggleBtn.getAttribute("data-dc-av-toggle"));
@ -2998,6 +3180,16 @@ document.addEventListener("DOMContentLoaded", async () => {
// esta aba). Observações (contas/DRE/achados) já estão todas carregadas
// em apuracaoAtual, não precisam de fetch próprio.
async function renderDashboardTab() {
// Apuração do Contabit não usa indicadores (ContabilApuracao.tem_indicadores):
// some o botão "Gerenciar Indicadores", a dica e os cards, sem buscar nada.
const temIndicadores = apuracaoAtual.tem_indicadores !== false;
["dc-indicadores-gerenciar-btn", "dc-dash-indicadores-dica", "dc-dash-indicadores"].forEach((id) => {
document.getElementById(id).hidden = !temIndicadores;
});
if (!temIndicadores) {
renderDashboardObservacoes();
return;
}
if (!dcIndicadoresAtual) {
dcIndicadoresAtual = await pidBuscarIndicadoresContabil(apuracaoAtual.id);
}

View File

@ -973,6 +973,7 @@
<div class="dcr-resumo-fechamento__texto">{{ apuracao.resumo_fechamento|safe }}</div>
</div>
{% endif %}
{% if indicadores_grupos %}
<div class="dcr-secao">
{% for grupo in indicadores_grupos %}
<h2>
@ -1007,6 +1008,7 @@
</div>
{% endfor %}
</div>
{% endif %}
<div class="dcr-secao">
<h2>

View File

@ -334,7 +334,7 @@
<div class="dc-header">
<div>
<h2>Relatório Contábil</h2>
<p class="dc-subtitle">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.</p>
<p class="dc-subtitle">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").</p>
</div>
<button type="button" class="btn-solid" id="dc-new-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14" stroke-linecap="round"/></svg>
@ -563,6 +563,12 @@
<div class="dc-dash-obs-list" id="dc-av-obs-list"></div>
<p class="dc-empty" id="dc-av-obs-empty" hidden>Nenhuma observação registrada na Análise Vertical ainda.</p>
</section>
<section class="dc-obs-resumo dc-variacoes">
<h3 class="dc-obs-resumo__titulo">Variação atípica na DRE - Análise Vertical <span class="dc-obs-resumo__count" id="dc-av-variacoes-count">0</span></h3>
<p class="dc-variacoes__dica">Confira cada variação e clique em "Validar". A observação escrita aqui é a mesma da linha na tabela: fica no histórico da empresa e pode ser mostrada ao cliente.</p>
<div class="dc-variacoes__lista" id="dc-av-variacoes-list"></div>
<p class="dc-empty" id="dc-av-variacoes-empty" hidden>Nenhuma variação atípica nesta análise.</p>
</section>
</div>
<div class="pa-tab-panel" data-dc-panel="dashboard" hidden>
@ -586,7 +592,7 @@
</div>
</div>
<p class="dc-dash-section__dica">Clique num card pra ver a fórmula usada.</p>
<p class="dc-dash-section__dica" id="dc-dash-indicadores-dica">Clique num card pra ver a fórmula usada.</p>
<div id="dc-dash-indicadores"></div>
</section>