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($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 --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\" 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": [ "additionalDirectories": [
"C:\\Users\\Depaula\\AppData\\Local\\Temp\\claude\\c--Users-Depaula-Documents-Portal\\3ea0ee22-e5fd-4030-98b1-ee2e71c16ce0\\scratchpad\\halloween-design", "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). 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`. 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. 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. 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.** **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. **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) ## 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. - **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. - **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. - **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). 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`) ## Motor de regras de auditoria (`regras.py`)
Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, list[SnapshotHistorico]) -> list[AchadoDetectado]`. 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. 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. 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"). 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 ### 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 ### 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`. - **`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`. - **`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. - **`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 ### 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. > **"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}/` | 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-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}/` | 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-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) | | `/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). 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. > 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"`. > **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" ### 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. 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 ## 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. - **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. - **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. - **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*`). > 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 ## 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`). - `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.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). - `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 datetime import date
from decimal import Decimal 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 @dataclass
class CabecalhoExtraido: class CabecalhoExtraido:
@ -21,7 +28,8 @@ class CabecalhoExtraido:
@dataclass @dataclass
class LinhaBalanceteExtraida: 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 codigo: str
descricao: str descricao: str
tipo: str # "S" (sintética) ou "A" (analítica) 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 # `ContabilApuracao.fonte_pdf_atipica`, só pra alertar o contador — não
# bloqueia nem tenta corrigir nada automaticamente. # bloqueia nem tenta corrigir nada automaticamente.
fonte_pdf_atipica: bool = False fonte_pdf_atipica: bool = False
leiaute: str = LEIAUTE_QUESTOR
@dataclass @dataclass

View File

@ -1,6 +1,6 @@
"""Extração do PDF de Balancete + DRE (leiaute Questor, único leiaute-fonte — """Extração do PDF de Balancete + DRE. Dois leiautes: Questor (este módulo) e
ao contrário de `portal_api.planos_saude`, que precisa de um parser por Contabit (`parser_contabit.py`) — `extrai_balancete_dre()` detecta qual é
operadora, aqui o relatório é sempre gerado pelo mesmo sistema contábil). 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 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 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 re
import unicodedata import unicodedata
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import IO from typing import IO
import pdfplumber 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 ( from .modelos import (
CabecalhoExtraido, CabecalhoExtraido,
LinhaAnaliseVerticalExtraida, 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" 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]: def _reconstroi_linhas(pagina) -> list[dict]:
caracteres = [c for c in pagina.chars if c["text"] != " "] caracteres = [c for c in pagina.chars if c["text"] != " "]
linhas: dict[float, list] = {} 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: def extrai_balancete_dre(origem: str | IO[bytes], nome_arquivo: str | None = None) -> ResultadoExtracao:
"""Lê o PDF combinado de Balancete + DRE (modelo Questor) e devolve tudo """Lê o PDF combinado de Balancete + DRE e devolve tudo já estruturado.
já estruturado. `origem` aceita tanto um caminho em disco quanto um 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 arquivo já aberto em memória (`io.BytesIO`) — a view chama isto direto
sobre o upload, antes de saber `codigo_empresa`/`competencia` (extraídos 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. 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: with pdfplumber.open(origem) as pdf:
if not pdf.pages: if not pdf.pages:
raise ExtracaoInvalidaError("O PDF não tem páginas.") 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 = [ primeiras_linhas_pagina1 = [
linha["texto"] for linha in _reconstroi_linhas(pdf.pages[0]) if linha["texto"].strip() 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( 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: ) -> 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) historico = busca_historico(extracao.cabecalho)
achados = gera_achados(extracao, historico) achados = gera_achados(extracao, historico)
return ResultadoProcessamento(extracao=extracao, achados=achados) return ResultadoProcessamento(extracao=extracao, achados=achados)

View File

@ -11,7 +11,13 @@ from __future__ import annotations
import re import re
from decimal import Decimal 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, # Exclui "NUMERÁRIOS EM TRANSITO"/"... EM TRANSITO" (dinheiro em trânsito,
# conceito diferente de conta transitória/de compensação) do match de # 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. # saldo desta linha agregadora, não de uma das duas filhas específicas.
CODIGO_LUCRO_PREJUIZO_EXERCICIO = "2.04.13.002" 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_LIMIAR_PERCENTUAL = Decimal("0.65") # 65%
VARIACAO_VALOR_MINIMO = Decimal("1000") # ignora variações abaixo disso, mesmo que %-mente grandes 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) # 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). # relativa, mas irrelevante em termos de composição da receita).
VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO = Decimal("1") 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: def _moeda(valor: Decimal) -> str:
"""Formata no padrão brasileiro (ponto de milhar, vírgula decimal) — """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) passivo = next((c for c in atual.contas if c.codigo == "2"), None)
if ativo is None or passivo is None: if ativo is None or passivo is None:
return [] 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: if diferenca == 0:
return [] return []
return [ return [
@ -84,7 +107,7 @@ def regra_balanceamento_ativo_passivo(atual: ResultadoExtracao, historico: list[
titulo="Ativo não bate com Passivo", titulo="Ativo não bate com Passivo",
mensagem=( mensagem=(
f"Saldo do Ativo ({_moeda(ativo.saldo_atual)}) não coincide com o do Passivo " 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, 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]: def regra_saldo_negativo_caixa(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
achados = [] achados = []
prefixo = PREFIXO_CAIXA_CONTABIT if atual.leiaute == LEIAUTE_CONTABIT else PREFIXO_CAIXA_QUESTOR
for conta in atual.contas: 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( achados.append(
AchadoDetectado( AchadoDetectado(
regra="saldo_negativo_caixa", regra="saldo_negativo_caixa",
@ -204,7 +228,42 @@ def _indices_descendentes_de_conta_redutora(contas: list[LinhaBalanceteExtraida]
return descendentes 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]: def regra_saldo_sinal_invertido(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
if atual.leiaute == LEIAUTE_CONTABIT:
return _saldo_sinal_invertido_contabit(atual)
achados = [] achados = []
descendentes_de_redutora = _indices_descendentes_de_conta_redutora(atual.contas) descendentes_de_redutora = _indices_descendentes_de_conta_redutora(atual.contas)
for i, conta in enumerate(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]: 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 """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 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 invertido no relatório do Questor (ver `regra_balanceamento_ativo_passivo`),
o saldo da linha do Balancete precisa ser negado antes de comparar com a então o saldo da linha do Balancete precisa ser negado antes de comparar
última linha da DRE.""" com a última linha da DRE. No Contabit o saldo já sai pela natureza
conta_lucro = next((c for c in atual.contas if c.codigo == CODIGO_LUCRO_PREJUIZO_EXERCICIO), None) (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: if conta_lucro is None or not atual.linhas_dre:
return [] 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 lucro_dre = atual.linhas_dre[-1].valor
diferenca = lucro_balancete - lucro_dre diferenca = lucro_balancete - lucro_dre
if diferenca == 0: if diferenca == 0:
@ -308,7 +370,7 @@ def regra_variacao_atipica_dre(atual: ResultadoExtracao, historico: list[Snapsho
AchadoDetectado( AchadoDetectado(
regra="variacao_atipica_dre", regra="variacao_atipica_dre",
severidade=SEVERIDADE_BAIXA, severidade=SEVERIDADE_BAIXA,
titulo="Variação atípica na DRE", titulo=TITULO_VARIACAO_ATIPICA_DRE,
mensagem=( mensagem=(
f'Linha "{linha.descricao}" da DRE foi de {mes_anterior.percentual:.2f}% da Receita Bruta em ' 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} " 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 # este campo só pra mostrar um aviso ao lado do nome da empresa, pedindo
# atenção a esse risco, não pra bloquear nada. # 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) 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: class Meta:
verbose_name = "Apuração do Relatório Contábil" 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 # criação, mesmo papel de ContabilLinhaDre.ordem) e é o que garante que
# a árvore do Balancete (ver dashboard-contabil.js) não misture grupos. # a árvore do Balancete (ver dashboard-contabil.js) não misture grupos.
ordem = models.IntegerField("Ordem", default=0) 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) codigo = models.CharField("Classificação", max_length=60)
descricao = models.CharField("Descrição", max_length=255) descricao = models.CharField("Descrição", max_length=255)
tipo = models.CharField("Tipo", max_length=1) # "S" sintética | "A" analítica 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) 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): class ContabilAchadoOcultoSerializer(serializers.Serializer):
"""Corpo de `ContabilAchadoViewSet.alternar_oculto()` — separado de """Corpo de `ContabilAchadoViewSet.alternar_oculto()` — separado de
`ContabilAchadoAjusteSerializer` porque ocultar do relatório não deve `ContabilAchadoAjusteSerializer` porque ocultar do relatório não deve
@ -2734,6 +2742,7 @@ class ContabilApuracaoListSerializer(serializers.ModelSerializer):
"concluida_em", "concluida_em",
"total_achados_pendentes", "total_achados_pendentes",
"fonte_pdf_atipica", "fonte_pdf_atipica",
"leiaute",
"reprocessamentos", "reprocessamentos",
] ]
@ -2747,6 +2756,7 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
linhas_dre = ContabilLinhaDreSerializer(many=True, read_only=True) linhas_dre = ContabilLinhaDreSerializer(many=True, read_only=True)
linhas_analise_vertical = ContabilLinhaAnaliseVerticalSerializer(many=True, read_only=True) linhas_analise_vertical = ContabilLinhaAnaliseVerticalSerializer(many=True, read_only=True)
achados = ContabilAchadoSerializer(many=True, read_only=True) achados = ContabilAchadoSerializer(many=True, read_only=True)
tem_indicadores = serializers.BooleanField(read_only=True)
class Meta: class Meta:
model = ContabilApuracao model = ContabilApuracao
@ -2764,6 +2774,8 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
"concluida_em", "concluida_em",
"resumo_fechamento", "resumo_fechamento",
"fonte_pdf_atipica", "fonte_pdf_atipica",
"leiaute",
"tem_indicadores",
"contas", "contas",
"linhas_dre", "linhas_dre",
"analise_vertical_meses", "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 .templatetags import contabil_extras as dashboard_contabil_extras
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
from .dashboard_contabil.modelos import SnapshotHistorico as ContabilSnapshotHistorico 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 .dashboard_contabil.parser import ExtracaoInvalidaError as ContabilExtracaoInvalidaError
from .models import ( from .models import (
AcessoGeral, AcessoGeral,
@ -133,6 +134,7 @@ from .serializers import (
CompromissoAgendaSerializer, CompromissoAgendaSerializer,
ContabilAchadoAjusteSerializer, ContabilAchadoAjusteSerializer,
ContabilAchadoOcultoSerializer, ContabilAchadoOcultoSerializer,
ContabilAchadoValidarSerializer,
ContabilAchadoSerializer, ContabilAchadoSerializer,
ContabilApuracaoCreateSerializer, ContabilApuracaoCreateSerializer,
ContabilApuracaoDetailSerializer, ContabilApuracaoDetailSerializer,
@ -3730,10 +3732,16 @@ def _contabil_dados_resumo(apuracao: ContabilApuracao) -> dict[str, Any]:
(`ContabilApuracaoViewSet.resumo_pdf()`, ver `resumo_pdf.py`). Devolve (`ContabilApuracaoViewSet.resumo_pdf()`, ver `resumo_pdf.py`). Devolve
`observacoes_visiveis` **sem** separar por `alvo_tipo` nem calcular `observacoes_visiveis` **sem** separar por `alvo_tipo` nem calcular
`ancora` — isso é específico de cada consumidor (o relatório HTML separa `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).""" 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) dados = _contabil_coleta_dados_indicadores(apuracao)
valores_indicadores = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados) valores_indicadores = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados)
cards = _contabil_monta_cards_indicadores(apuracao, valores_indicadores) 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")) 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 # Observações que o contador marcou pra mostrar ao cliente, já no recorte
# de vigência desta competência (ver `ContabilObservacao`) — inclui as # 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 if observacao.mostrar_ao_cliente
] ]
return { return {
"indicadores_grupos": _contabil_agrupa_indicadores_cards(cards), "indicadores_grupos": indicadores_grupos,
"observacoes_visiveis": observacoes_visiveis, "observacoes_visiveis": observacoes_visiveis,
# Cards que o contador escondeu na aba "Dashboard" da tela de revisão # Cards que o contador escondeu na aba "Dashboard" da tela de revisão
# (ver `indicadores_ocultos()`) já ficam de fora de `cards` acima; # (ver `indicadores_ocultos()`) já ficam de fora de `cards` acima;
@ -4176,18 +4184,10 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
try: try:
resultado = dashboard_contabil_pipeline.processa_apuracao( resultado = dashboard_contabil_pipeline.processa_apuracao(
io.BytesIO(conteudo), _contabil_monta_historico io.BytesIO(conteudo), _contabil_monta_historico, arquivo.name
)
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,
) )
except ContabilExtracaoInvalidaError as exc:
return _contabil_resposta_extracao_invalida(exc)
cabecalho = resultado.extracao.cabecalho cabecalho = resultado.extracao.cabecalho
competencia = cabecalho.periodo_fim.replace(day=1) competencia = cabecalho.periodo_fim.replace(day=1)
@ -4211,6 +4211,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
criado_por=request.user, criado_por=request.user,
analise_vertical_meses=resultado.extracao.meses_analise_vertical, analise_vertical_meses=resultado.extracao.meses_analise_vertical,
fonte_pdf_atipica=resultado.extracao.fonte_pdf_atipica, fonte_pdf_atipica=resultado.extracao.fonte_pdf_atipica,
leiaute=resultado.extracao.leiaute,
) )
ContabilConta.objects.bulk_create( ContabilConta.objects.bulk_create(
[ [
@ -4339,18 +4340,10 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
try: try:
resultado = dashboard_contabil_pipeline.processa_apuracao( resultado = dashboard_contabil_pipeline.processa_apuracao(
io.BytesIO(conteudo), _contabil_monta_historico io.BytesIO(conteudo), _contabil_monta_historico, arquivo.name
)
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,
) )
except ContabilExtracaoInvalidaError as exc:
return _contabil_resposta_extracao_invalida(exc)
cabecalho = resultado.extracao.cabecalho cabecalho = resultado.extracao.cabecalho
nova_competencia = cabecalho.periodo_fim.replace(day=1) nova_competencia = cabecalho.periodo_fim.replace(day=1)
@ -4358,8 +4351,9 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
return Response( return Response(
{ {
"detail": ( "detail": (
f"O arquivo anexado é de {cabecalho.nome_empresa} ({nova_competencia:%m/%Y}), " f"O arquivo anexado é de {cabecalho.codigo_empresa} - {cabecalho.nome_empresa} "
f"diferente desta análise ({apuracao.nome_empresa} — {apuracao.competencia:%m/%Y}). " 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." "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.periodo_fim = cabecalho.periodo_fim
apuracao.analise_vertical_meses = resultado.extracao.meses_analise_vertical apuracao.analise_vertical_meses = resultado.extracao.meses_analise_vertical
apuracao.fonte_pdf_atipica = resultado.extracao.fonte_pdf_atipica apuracao.fonte_pdf_atipica = resultado.extracao.fonte_pdf_atipica
apuracao.leiaute = resultado.extracao.leiaute
apuracao.arquivo = ContentFile(conteudo, name=arquivo.name) apuracao.arquivo = ContentFile(conteudo, name=arquivo.name)
apuracao.save() apuracao.save()
novo_arquivo_salvo = True 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 de verdade — não têm mais tratamento especial aqui, ver CLAUDE.md do
pacote.""" pacote."""
apuracao = self.get_object() 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) dados = _contabil_coleta_dados_indicadores(apuracao)
selecionados = apuracao.indicadores_selecionados selecionados = apuracao.indicadores_selecionados
valores = _contabil_calcula_indicadores_personalizados(dados, selecionados) valores = _contabil_calcula_indicadores_personalizados(dados, selecionados)
@ -4771,6 +4778,20 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
return response 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: def _contabil_garante_em_revisao(apuracao: ContabilApuracao) -> None:
if apuracao.status == ContabilApuracao.STATUS_CONCLUIDA: if apuracao.status == ContabilApuracao.STATUS_CONCLUIDA:
raise ValidationError( raise ValidationError(
@ -5032,6 +5053,33 @@ class ContabilAchadoViewSet(viewsets.ModelViewSet):
achado.save(update_fields=["oculto_no_relatorio"]) achado.save(update_fields=["oculto_no_relatorio"])
return Response(ContabilAchadoSerializer(achado).data) 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: def _gera_chave_indicador_contabil(nome: str) -> str:
"""Deriva a `chave` de um `IndicadorContabilDefinicao` a partir do """Deriva a `chave` de um `IndicadorContabilDefinicao` a partir do

View File

@ -717,6 +717,98 @@
flex-wrap: wrap; 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ó /* Expande/recolhe a conta usada no apontamento (achado.conta) — só
presente em achados vinculados a uma conta específica; regras gerais presente em achados vinculados a uma conta específica; regras gerais
(ex. balanceamento Ativo x Passivo) não têm esse toggle. */ (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_transitoria_com_saldo", label: "Contas Transitórias" },
{ chave: "conta_deveria_zerar", label: "Contas que Deveriam Zerar" }, { chave: "conta_deveria_zerar", label: "Contas que Deveriam Zerar" },
{ chave: "descricao_generica", label: "Descrição Genérica" }, { 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" }, { chave: "item_removido_reprocessamento", label: "Contas e Linhas Removidas" },
]; ];
@ -46,10 +46,8 @@ const PID_DC_GRUPOS = [
label: "Contas Atípicas", label: "Contas Atípicas",
regras: ["conta_transitoria_com_saldo", "conta_deveria_zerar", "descricao_generica"], regras: ["conta_transitoria_com_saldo", "conta_deveria_zerar", "descricao_generica"],
}, },
{ // "Variações e Indicadores" (variacao_atipica_dre) saiu daqui: essas
label: "Variações e Indicadores", // variações só aparecem no fim da aba Análise Vertical.
regras: ["variacao_atipica_dre"],
},
// `somenteComAchados`: só aparece na apuração em que algo foi de fato // `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 // 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 // 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") ---- // ---- Aba "Dashboard" (pré-visualização do relatório "Gerar Dashboard") ----
async function pidBuscarIndicadoresContabil(id) { async function pidBuscarIndicadoresContabil(id) {
@ -1098,7 +1103,7 @@ document.addEventListener("DOMContentLoaded", async () => {
dcDreDestaque = dcUltimaLevaVisivel(apuracao.linhas_dre, (linha) => Math.max(0, linha.nivel), dcDreColapsadas); dcDreDestaque = dcUltimaLevaVisivel(apuracao.linhas_dre, (linha) => Math.max(0, linha.nivel), dcDreColapsadas);
dcContasExpandidos = new Set(); dcContasExpandidos = new Set();
dcDreExpandidos = 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; dcObsEditandoId = null;
dcAchadosContaExpandida = new Set(); dcAchadosContaExpandida = new Set();
dcIndicadoresAtual = null; dcIndicadoresAtual = null;
@ -1137,8 +1142,16 @@ document.addEventListener("DOMContentLoaded", async () => {
const PID_DC_SEVERIDADE_ORDEM = { alta: 0, media: 1, baixa: 2 }; 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() { function achadosFiltrados() {
return apuracaoAtual.achados return dcAchadosDaAbaObservacoes()
.filter((achado) => { .filter((achado) => {
if (filtroSeveridade !== "todas" && achado.severidade !== filtroSeveridade) return false; if (filtroSeveridade !== "todas" && achado.severidade !== filtroSeveridade) return false;
if (filtroStatusAchado === "pendente" && achado.status !== "pendente") 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 // visão geral estável, mesmo espírito do "Distribuição das Análises" do
// sistema externo que inspirou esta seção. // sistema externo que inspirou esta seção.
function renderAchadosResumo() { function renderAchadosResumo() {
const achados = apuracaoAtual.achados; const achados = dcAchadosDaAbaObservacoes();
const total = achados.length; const total = achados.length;
// ---- Donut por severidade ---- // ---- Donut por severidade ----
@ -1321,8 +1334,7 @@ document.addEventListener("DOMContentLoaded", async () => {
</div> </div>
<div class="dc-achado-card__actions"> <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>` : ""} ${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>` : ""} ${podeEditar ? `<button type="button" class="btn-outline" data-dc-achado-editar="${achado.id}">Revisar</button>` : ""} </div>
</div>
`; `;
lista.appendChild(card); lista.appendChild(card);
}); });
@ -1790,8 +1802,8 @@ document.addEventListener("DOMContentLoaded", async () => {
// badges abaixo. `gatilhoHtml` é o ícone (já pronto, com sua própria // badges abaixo. `gatilhoHtml` é o ícone (já pronto, com sua própria
// classe/cor); `texto` vai só no `<span>` do tooltip (nunca em `title`, // 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). // que voltaria a mostrar o balão nativo do navegador em cima do custom).
function pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto) { function pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto, classeExtra = "") {
return `<span class="dc-hover-tooltip"> 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__gatilho ${gatilhoClasse}" tabindex="0" aria-label="${pidDcEscapeHtml(texto)}">${gatilhoHtml}</span>
<span class="dc-hover-tooltip__texto" role="tooltip">${pidDcEscapeHtml(texto)}</span> <span class="dc-hover-tooltip__texto" role="tooltip">${pidDcEscapeHtml(texto)}</span>
</span>`; </span>`;
@ -1870,7 +1882,12 @@ document.addEventListener("DOMContentLoaded", async () => {
// Qual linha está com o painel de observações aberto, por tabela (no // Qual linha está com o painel de observações aberto, por tabela (no
// máximo uma por tabela, mesmo espírito dos antigos dcContaObsEditId/ // máximo uma por tabela, mesmo espírito dos antigos dcContaObsEditId/
// dcDreObsEditId/dcAvObsEditId que este objeto substituiu). // 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ó // 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). // enquanto ela for da competência aberta (histórica nunca é editável).
let dcObsEditandoId = null; let dcObsEditandoId = null;
@ -2117,6 +2134,13 @@ document.addEventListener("DOMContentLoaded", async () => {
// embaixo. Uma observação nunca é sobrescrita — escrever de novo cria mais // embaixo. Uma observação nunca é sobrescrita — escrever de novo cria mais
// um item na thread. // um item na thread.
function dcObsPainelHtml(tipo, alvoId, observacoes, colspan, concluida) { 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 const thread = observacoes.length
? observacoes.map((obs) => dcObsItemHtml(obs, concluida)).join("") ? observacoes.map((obs) => dcObsItemHtml(obs, concluida)).join("")
: '<p class="dc-obs-thread__vazio">Nenhuma observação registrada até agora.</p>'; : '<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="dc-obs-nova">
<div class="modal-field"> <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> </div>
<label class="modal-checkbox"> <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 Mostrar esta observação ao cliente no relatório
</label> </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"> <div class="modal-actions">
<button type="button" class="btn-outline" data-dc-obs-fechar="${tipo}">Fechar</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}">Salvar observação</button> <button type="button" class="btn-solid" data-dc-obs-criar="${tipo}:${alvoId}:${escopo}">Salvar observação</button>
</div> </div>
</div>`; </div>`;
const rodape = concluida const rodape = concluida
? '<p class="dc-obs-thread__vazio">Análise concluída, histórico somente leitura.</p>' ? '<p class="dc-obs-thread__vazio">Análise concluída, histórico somente leitura.</p>'
: ""; : "";
return ` return `
<td colspan="${colspan}">
<div class="dc-obs-edit"> <div class="dc-obs-edit">
<div class="dc-obs-thread">${thread}</div> <div class="dc-obs-thread">${thread}</div>
${nova}${rodape} ${nova}${rodape}
</div> </div>
</td>
`; `;
} }
@ -2159,6 +2181,8 @@ document.addEventListener("DOMContentLoaded", async () => {
const [tipoBtn, idTexto] = abrirBtn.getAttribute("data-dc-obs-abrir").split(":"); const [tipoBtn, idTexto] = abrirBtn.getAttribute("data-dc-obs-abrir").split(":");
const id = Number(idTexto); const id = Number(idTexto);
dcObsPainelAberto[tipoBtn] = dcObsPainelAberto[tipoBtn] === id ? null : id; dcObsPainelAberto[tipoBtn] = dcObsPainelAberto[tipoBtn] === id ? null : id;
if (tipoBtn === "variacao") dcObsPainelAberto.analise_vertical = null;
if (tipoBtn === "analise_vertical") dcObsPainelAberto.variacao = null;
dcObsEditandoId = null; dcObsEditandoId = null;
dcRenderTabelaDoTipo(tipo); dcRenderTabelaDoTipo(tipo);
return true; return true;
@ -2174,11 +2198,12 @@ document.addEventListener("DOMContentLoaded", async () => {
const criarBtn = event.target.closest("[data-dc-obs-criar]"); const criarBtn = event.target.closest("[data-dc-obs-criar]");
if (criarBtn) { 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 alvoId = Number(idTexto);
const erroEl = document.getElementById(`dc-obs-nova-erro-${alvoTipo}-${alvoId}`); const escopo = escopoTexto || alvoTipo;
const texto = document.getElementById(`dc-obs-nova-texto-${alvoTipo}-${alvoId}`).value.trim(); const erroEl = document.getElementById(`dc-obs-nova-erro-${escopo}-${alvoId}`);
const mostrar = document.getElementById(`dc-obs-nova-mostrar-${alvoTipo}-${alvoId}`).checked; 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 = ""; erroEl.textContent = "";
if (!texto) { if (!texto) {
erroEl.textContent = "Escreva a observação antes de salvar."; 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 (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"); if (dcFocoLinha && dcFocoLinha.tipo === "conta" && dcFocoLinha.id === conta.id) tr.classList.add("dc-conta-row--foco");
tr.innerHTML = ` tr.innerHTML = `
<td>${pidDcEscapeHtml(conta.conta_numero)}</td> <td>${pidDcEscapeHtml(conta.conta_numero ?? "")}</td>
<td>${pidDcEscapeHtml(conta.codigo)}</td> <td>${pidDcEscapeHtml(conta.codigo)}</td>
<td style="padding-left: calc(var(--space-4) + ${nivel * 18}px)"> <td style="padding-left: calc(var(--space-4) + ${nivel * 18}px)">
<span class="dc-conta-desc-cell">${toggle}<span>${pidDcEscapeHtml(conta.descricao)}</span></span> <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 niveis = linhas.map(nivelFn);
const temFilhos = linhas.map((_, i) => i + 1 < linhas.length && niveis[i + 1] > niveis[i]); 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 = []; const pilhaColapso = [];
linhas.forEach((linha, i) => { linhas.forEach((linha, i) => {
const nivel = niveis[i]; const nivel = niveis[i];
@ -2647,6 +2679,7 @@ document.addEventListener("DOMContentLoaded", async () => {
${colunasMensais} ${colunasMensais}
<td> <td>
<div class="dc-obs-cell-actions"> <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" : ""}> <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} ${PID_DC_VALIDADO_ICONE}
</button> </button>
@ -2667,9 +2700,158 @@ document.addEventListener("DOMContentLoaded", async () => {
dcAtualizaBotaoArvore("analise-vertical", dcAvColapsadas, "da Análise Vertical"); dcAtualizaBotaoArvore("analise-vertical", dcAvColapsadas, "da Análise Vertical");
renderAnaliseVerticalObsResumo(); 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) => { 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]"); const toggleBtn = event.target.closest("[data-dc-av-toggle]");
if (toggleBtn) { if (toggleBtn) {
const id = Number(toggleBtn.getAttribute("data-dc-av-toggle")); 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 // esta aba). Observações (contas/DRE/achados) já estão todas carregadas
// em apuracaoAtual, não precisam de fetch próprio. // em apuracaoAtual, não precisam de fetch próprio.
async function renderDashboardTab() { 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) { if (!dcIndicadoresAtual) {
dcIndicadoresAtual = await pidBuscarIndicadoresContabil(apuracaoAtual.id); dcIndicadoresAtual = await pidBuscarIndicadoresContabil(apuracaoAtual.id);
} }

View File

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

View File

@ -334,7 +334,7 @@
<div class="dc-header"> <div class="dc-header">
<div> <div>
<h2>Relatório Contábil</h2> <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> </div>
<button type="button" class="btn-solid" id="dc-new-btn"> <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> <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> <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> <p class="dc-empty" id="dc-av-obs-empty" hidden>Nenhuma observação registrada na Análise Vertical ainda.</p>
</section> </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>
<div class="pa-tab-panel" data-dc-panel="dashboard" hidden> <div class="pa-tab-panel" data-dc-panel="dashboard" hidden>
@ -586,7 +592,7 @@
</div> </div>
</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> <div id="dc-dash-indicadores"></div>
</section> </section>