Validações no reprocessamento

This commit is contained in:
Gabriel 2026-09-22 13:40:59 -03:00
parent 302bbea163
commit e4d95266ef
12 changed files with 616 additions and 87 deletions

View File

@ -139,7 +139,18 @@
"Bash(cp \"C:/Users/Depaula/Documents/Logos/P.I.D. Logo Design Aniversário/Logo intro animation/export-56/Logo - DE PAULA CONTADORES - escrita branca \\(1\\).png\" \"C:/Users/Depaula/Documents/Portal/static/img/pid-aniversario-depaula-wordmark.png\" *)",
"Bash(grep -o '`' \"C:/Users/Depaula/Documents/Portal/static/js/login-intro-aniversario.js\")",
"Bash(\"./.venv/Scripts/python.exe\" -)",
"Bash(grep -n \"codigo_empresa\\\\` \\(código da empresa\\\\|unimed_1778_tecnomyl\\\\*\\\\*\\\\|Trava de compatibilidade generalizada\" portal_api/planos_saude/CLAUDE.md)"
"Bash(grep -n \"codigo_empresa\\\\` \\(código da empresa\\\\|unimed_1778_tecnomyl\\\\*\\\\*\\\\|Trava de compatibilidade generalizada\" portal_api/planos_saude/CLAUDE.md)",
"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\\\\simula_sync.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\\\\simula_sync2.py\")",
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" manage.py check)",
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" manage.py makemigrations --check --dry-run)",
"PowerShell(node --version)",
"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_chaves.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_migracao.py\")",
"PowerShell($f = \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\portal_api\\\\dashboard_contabil\\\\CHANGELOG.md\"; \\(Get-Content $f | Measure-Object -Line\\).Lines)",
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" manage.py makemigrations portal_api --name contabil_ordering_determinista --dry-run -v 2)",
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" manage.py makemigrations portal_api --name contabil_ordering_determinista)",
"PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\33b72ffc-a7b3-4b7e-8afe-bb65121e9a85\\\\scratchpad\\\\testa_removidos.py\")"
],
"additionalDirectories": [
"C:\\Users\\Depaula\\AppData\\Local\\Temp\\claude\\c--Users-Depaula-Documents-Portal\\3ea0ee22-e5fd-4030-98b1-ee2e71c16ce0\\scratchpad\\halloween-design",

View File

@ -124,7 +124,7 @@ Pacotes Python puros (sem ORM), um por ferramenta — cada um com seu próprio `
| `custo_contratacao/` | "Simulação de Custo de Contratação" (Geradoc) — `tabelas.py` (faixas de INSS/IRRF), `calculo.py`, `pdf.py` via `reportlab`. |
| `indicadores/` | "Indicador de Desempenho" (Geradoc) — `tipos.py`, `leiaute.py` (`openpyxl`), `pipeline.py`, `entregas.py`, `calculo.py`, `recibo.py` (PDF via `reportlab`), `departamentos.py`. |
| `nao_conformidades/` | "Não Conformidades" (Relatórios > Qualidade) — leiautes dos exports do Sigsistem, `diff.py` (reabertura automática), `classificacao.py`, `pipeline.py`. |
| `dashboard_contabil/` | "Relatório Contábil" (Relatórios > Contabilidade) — `parser.py` (extração do PDF), `regras.py` (motor de auditoria), `formula.py` (avaliador de fórmula por `ast`), `indicadores.py`, `exportacao.py` (XLSX), `resumo_pdf.py`. |
| `dashboard_contabil/` | "Relatório Contábil" (Relatórios > Contabilidade) — `parser.py` (extração do PDF), `regras.py` (motor de auditoria), `formula.py` (avaliador de fórmula por `ast`), `indicadores.py`, `chaves.py` (chave natural de conta/linha, compartilhada por sincronização, observações e relatório), `exportacao.py` (XLSX), `resumo_pdf.py`. |
### API (sessão + CSRF, não token)

View File

@ -435,3 +435,30 @@ O `CLAUDE.md` desta pasta tinha virado um segundo changelog: 65% dele (129 KB de
Bug reportado pelo usuário: em balancetes largos (empresa com muitas contas/valores grandes), o botão "Restaurar formatação padrão" no cabeçalho "Observação" (e o ícone de observação de cada linha) ficava fora da área visível de `.dcr-tabela-wrap`, só alcançável arrastando a tabela inteira pro lado — o comentário em `dashboard-contabil-relatorio.html` que introduziu o `overflow-x:auto` do wrap (ver `CLAUDE.md` desta pasta) assumia que Balancete/D.R.E. sempre coubessem sem precisar de scroll, o que não é verdade pra toda empresa/competência.
Corrigido com `position: sticky; right: 0` na última coluna (`th`/`td.dcr-col-observacao`, nova classe) das três tabelas (Balancete, D.R.E., Análise Vertical) — a coluna some da rolagem: fica sempre visível na borda direita, aconteça o que acontecer com a largura das demais. Não se aplica à linha de observação expandida (`<td colspan>`), que não recebe a classe nova. Sem mudança de comportamento pra quem não precisa rolar (tabela estreita continua igual); a rolagem em si continua existindo como rede de segurança pras demais colunas.
### Rodada 151 — Linha da DRE removida do PDF sobrevivia ao reprocessamento (chave natural ambígua)
Bug reportado pelo usuário com print da tela: retirou "DESPESAS COM PESSOAL" de dentro de "DESPESAS DE VENDAS" na DRE, reprocessou, e a linha continuou lá — com um valor (R$ 106.667,83) maior que o do próprio grupo pai já atualizado (R$ 60.973,39), o que é impossível numa DRE consistente.
**Causa**: as três funções de sincronização montavam o mapa das linhas já salvas por dict comprehension (`{chave: linha}`). Como a chave natural da DRE/Análise Vertical era `(descricao, nivel)`, e o mesmo rótulo aparece em ramos diferentes no mesmo nível ("DESPESAS COM PESSOAL" existe também sob "DESPESAS ADMINISTRATIVAS"), o dict guardava só a última ocorrência. A outra virava um fantasma: fora do dict, nunca era atualizada; fora do laço final de exclusão (que varre o dict, não a tabela), nunca era excluída. Ficava congelada com o valor da importação original, imune a todo reprocessamento seguinte e sem nem o badge de `alterada_reprocessamento`. A mesma ambiguidade fazia uma observação escrita numa das linhas homônimas aparecer na outra — o mesmo defeito que a rodada da migração `0073` corrigiu no Balancete acrescentando a descrição à chave.
Reproduzido antes de mexer no código, simulando `_contabil_sincroniza_linhas_dre()` fora do Django com uma DRE de duas ocorrências: saída idêntica ao print do usuário, com `excluidas: nenhuma`. Depois da correção, a mesma simulação exclui a linha do ramo certo e preserva a do outro ramo com o valor novo.
**Duas correções, nas três tabelas**:
1. **Chave por caminho na árvore** (módulo novo `dashboard_contabil/chaves.py`, Python puro): a chave da DRE/Análise Vertical passou de `"descricao|nivel"` para `"grupo > subgrupo > descricao|nivel"`, montada percorrendo as linhas com a pilha de ancestrais (mesma técnica de `regras._indices_descendentes_de_conta_redutora()`, não comparação de prefixo). A chave do Balancete continua `(codigo, descricao)` — ali o código de classificação já carrega a posição na árvore. O módulo é a fonte única das três definições que não podem divergir: sincronização, `ContabilObservacao.alvo_chave` e os mapas de âncora do relatório. Efeito colateral da chave deixar de ser calculável por linha isolada: `_contabil_chave_alvo()` passou a receber a apuração, `_contabil_arvore_contexto()` recebe a lista de chaves alinhada em vez de uma `chave_fn`, e no JS a chave é calculada por tabela (`dcAplicaChavesObs()`, no começo de `renderDre()`/`renderAnaliseVertical()`) e guardada em `linha.chave_obs`.
2. **Fila por chave, não registro único** (`_contabil_agrupa_por_chave`/`_contabil_proxima_da_fila`/`_contabil_exclui_sobras`): mesmo com a chave nova, duas linhas irmãs genuinamente idênticas no mesmo ramo ainda colidem. Agora cada ocorrência do PDF novo consome uma da fila na ordem de leitura, e o que sobra é excluído — o número de linhas salvas passa a bater sempre com o do PDF, seja qual for a chave.
Migração `0078`: `alvo_chave` de `max_length=320` para `1000` (o caminho inteiro é bem mais longo que a descrição isolada, e truncar reintroduziria a ambiguidade) + reescrita das chaves das observações de DRE/Análise Vertical já gravadas, reconstruindo o caminho a partir das linhas da apuração de origem (ou, se ela tiver sido excluída, da apuração mais recente da mesma empresa). Quando a chave antiga era ambígua, a primeira ocorrência vence — não há como saber a qual das linhas homônimas a observação se referia, e é a mesma que o índice do frontend antigo casava. Observação cuja linha não for encontrada fica com a chave antiga: continua listada no resumo da aba e no relatório pelo `alvo_rotulo`, sem casar com nenhuma linha, exatamente como já acontece com uma conta que saiu do plano.
Migração `0079`: `ordering` dos três models de linha passou de `["ordem"]` para `["ordem", "id"]` (só `AlterModelOptions`, sem tocar em dado). O caminho de uma linha depende da posição dela na ordem de leitura, e `ordem` sozinha deixa a ordenação indefinida quando duas linhas empatam — situação que o próprio bug criava, já que a linha fantasma ficava com a `ordem` antiga enquanto as demais eram renumeradas. Os `order_by("ordem")` explícitos de `views.py` acompanharam.
Nada muda no que o cliente lê: `alvo_chave` só aparece em texto no relatório HTML e no PDF do Resumo para observações de **conta**, cuja chave não mudou.
Validado com `manage.py check` + `makemigrations --check` (estado de migração consistente com os models), teste da regra de caminho (rótulo repetido em dois ramos, nível pulado, nível negativo do parser, irmãs idênticas) e teste do mapeamento da migração, todos fora do banco. **Falta rodar `migrate` e reprocessar a apuração afetada.**
**Na mesma rodada, a pedido do usuário: conta/linha removida passou a aparecer na aba Observações.** Até aqui a exclusão era silenciosa — o item simplesmente sumia da tabela, e comparar duas versões da mesma apuração para descobrir o que saiu era trabalho manual. `regras.achados_itens_removidos()` monta um apontamento de severidade média por item excluído ("Conta removida no reprocessamento" / "Linha removida no reprocessamento", com o rótulo e a tabela de origem). Fica **fora** de `REGRAS` de propósito: `gera_achados()` só enxerga a extração do PDF atual, e "sumiu" só é visível comparando com o que estava salvo — por isso os três sincronizadores passaram a devolver `(origem, rótulo)` do que excluíram, e `reprocessar()` junta isso aos achados das regras antes de `_contabil_recria_achados()`. Decisões menores: rótulos iguais viram um apontamento só citando as duas tabelas (DRE e Análise Vertical são a mesma árvore, uma linha some das duas); o rótulo da DRE é o caminho inteiro, senão o aviso não diria qual das linhas homônimas saiu; `codigo_conta` não é preenchido, senão o achado casaria com outra conta de mesmo código que continua existindo. No JS, a chave nova entrou em `PID_DC_REGRAS` (rótulo "Contas e Linhas Removidas", filtro por categoria funciona igual) num grupo próprio marcado com `somenteComAchados`, que só aparece quando houve remoção — um card fixo em "0" em toda análise nunca reprocessada seria ruído, diferente das regras, cujo "0" informa que a checagem rodou e passou.
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.

View File

@ -52,6 +52,8 @@ Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, li
### As 9 regras
(Mais um 10º tipo de apontamento que não é regra, ver "conta/linha removida no reprocessamento" abaixo.)
1. **`balanceamento_ativo_passivo`** (alta) — soma do grupo Ativo (`codigo="1"`) deve fechar **exatamente** com a do Passivo (`codigo="2"`, que já vem negativo no relatório). Sem tolerância de centavos.
2. **`debito_credito_divergente`** (alta) — soma de Débito das contas-raiz (`codigo` sem ponto, ou seja só "1" e "2") deve bater **exatamente** com a soma de Crédito. **Não é uma checagem trivial**: como a DRE não tem colunas de débito/crédito próprias neste relatório (só um valor líquido por linha), a identidade só fecha porque a movimentação de Resultado transita pelas contas de Patrimônio Líquido do Passivo ("LUCROS/PREJUÍZOS DO EXERCÍCIO") — confirmado empiricamente contra o PDF de referência (débito total = crédito total = R$ 416.271.243,32 nas contas-raiz).
3. **`saldo_negativo_caixa`** (alta) — conta com `codigo` começando em `1.01.01.001` (grupo Caixa) e `saldo_atual < 0`.
@ -62,6 +64,14 @@ Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, li
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.
### O 10º apontamento não é uma regra: conta/linha removida no reprocessamento
`achados_itens_removidos(removidos)` (mesmo módulo, **fora** de `REGRAS`) monta um `AchadoDetectado` de severidade média por conta/linha que existia na apuração e não veio no arquivo do reprocessamento. Fica fora da lista porque `gera_achados()` só enxerga a extração do PDF atual, e "sumiu" só é visível comparando com o que estava salvo, que é o que a sincronização faz — quem chama é `reprocessar()` em `views.py`, juntando o resultado aos achados das regras antes de `_contabil_recria_achados()`. A construção do achado mora aqui mesmo assim, junto dos outros textos/severidades.
O rótulo é `"codigo descricao"` no Balancete e o **caminho na árvore** na DRE/Análise Vertical (sem o ramo, o aviso não diria qual das linhas homônimas saiu). Rótulos iguais viram um apontamento só citando as duas tabelas, já que DRE e Análise Vertical são a mesma árvore e uma linha retirada do PDF some das duas. `conta`/`linha_analise_vertical` ficam nulos (o registro foi excluído), então o card não mostra "Ver na tabela" — e `codigo_conta` **não** é preenchido de propósito: `_contabil_recria_achados()` casa código contra as contas que sobraram, e um código repetido entre contas analíticas apontaria para a conta errada.
> **O aviso vale para o reprocessamento em que a remoção aconteceu.** Achado é recriado do zero a cada reprocessamento e a linha já não está no banco, então reprocessar de novo com o mesmo arquivo não repete o aviso: naquele ponto não há mais nada sendo removido. É o mesmo espírito de `alterada_reprocessamento`, que também marca a mudança daquela rodada.
**Por que não existe uma regra de variação sobre o Balancete**: a Análise Vertical do PDF só cobre linhas da DRE. A alternativa (comparar saldo de conta contra a apuração anterior, via `historico`) existiu e foi removida a pedido do usuário, junto de uma regra de razão Custos/Receita — a granularidade de `variacao_atipica_dre` sobre a Análise Vertical já cobre qualquer linha da DRE sem precisar de regra dedicada.
### Descendente de conta redutora não dispara `saldo_sinal_invertido`
@ -91,7 +101,7 @@ Padrão cabeçalho → linhas de detalhe → apontamentos, mesma filosofia de `I
- **`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.
As três têm os mesmos recursos por linha: observação (via `ContabilObservacao`), tri-state de "validado" e ocultar do relatório.
As três têm os mesmos recursos por linha: observação (via `ContabilObservacao`), tri-state de "validado" e ocultar do relatório. E as três ordenam por `["ordem", "id"]`, não só por `ordem`: a árvore (o nível de cada linha em relação à anterior) e a chave natural da DRE/Análise Vertical dependem da ordem de leitura, então um empate de `ordem` não pode deixar o resultado à mercê do plano de execução do Postgres.
### Apontamentos de auditoria
@ -132,7 +142,11 @@ Os achados de `variacao_atipica_dre` são ligados à linha certa por **`ordem`**
Roda o mesmo `processa_apuracao()` de `create()`, troca o `arquivo` (apagando o antigo só **depois** do commit, pelo mesmo cuidado com storage não-transacional) e delega a resincronização para funções puras com **duas estratégias opostas**:
- **`_contabil_sincroniza_contas()` / `_linhas_dre()` / `_linhas_analise_vertical()` — atualização no lugar (mesmo `id`), nunca delete+recria.** Casam cada linha extraída contra a existente por chave natural: `(codigo, descricao)` no Balancete, `(descricao, nivel)` na DRE/Análise Vertical. Casada: atualiza os campos brutos no mesmo registro (`.save()`); se algum campo relevante mudou, força `validado=False` e `alterada_reprocessamento=True`, guardando o valor de antes em `valor_anterior_reprocessamento`/`valores_anterior_reprocessamento` (sempre lido **antes** de sobrescrever); senão preserva tudo, inclusive limpando esse campo. Sem match na extração nova: cria, com os defaults de sempre. Sobra no mapa antigo: `.delete()`.
- **`_contabil_sincroniza_contas()` / `_linhas_dre()` / `_linhas_analise_vertical()` — atualização no lugar (mesmo `id`), nunca delete+recria.** Casam cada linha extraída contra a existente por chave natural: `(codigo, descricao)` no Balancete, caminho na árvore + nível na DRE/Análise Vertical (ver `chaves.py` e "Observações" abaixo). Casada: atualiza os campos brutos no mesmo registro (`.save()`); se algum campo relevante mudou, força `validado=False` e `alterada_reprocessamento=True`, guardando o valor de antes em `valor_anterior_reprocessamento`/`valores_anterior_reprocessamento` (sempre lido **antes** de sobrescrever); senão preserva tudo, inclusive limpando esse campo. Sem match na extração nova: cria, com os defaults de sempre. Sobra no fim: `.delete()`.
> **O mapa de linhas já salvas é uma fila por chave (`_contabil_agrupa_por_chave()`), nunca um `{chave: linha}`.** Com dict, duas linhas de mesma chave viravam uma só: a perdedora ficava fora do mapa, então não era atualizada, e fora do laço de exclusão (que varre o mapa, não a tabela), então não era excluída — sobrevivia a todo reprocessamento com o valor congelado da primeira importação e sem nem o badge de `alterada_reprocessamento`. Foi assim que uma linha retirada do PDF continuou aparecendo na tela, com valor maior que o do próprio grupo pai. A chave por caminho resolve o caso comum (rótulo repetido em ramos diferentes), a fila cobre o resto (irmãs genuinamente idênticas no mesmo ramo): cada ocorrência do PDF novo consome uma da fila na ordem de leitura, e o que sobra é excluído, então a contagem de linhas salvas bate sempre com a do PDF.
Os três devolvem `(origem, rótulo)` de tudo que excluíram, e `reprocessar()` transforma isso em apontamento na aba Observações (`regras.achados_itens_removidos()`, ver acima) — a exclusão em si é silenciosa, e uma conta sumir entre um arquivo e outro é exatamente o tipo de mudança que o contador precisa conferir. Pedido explícito do usuário.
- **`_contabil_recria_achados()` — delete+recria total**, o mesmo `bulk_create` de `create()`. Todo achado nasce `pendente`, mesmo que a mesma `(regra, conta)` já estivesse tratada com justificativa escrita — a justificativa antiga some junto.
**Por que estratégias opostas**: conta/linha é dado extraído que o contador **anota** — o valor de hoje precisa ser atualizado, mas a anotação de ontem sobre a mesma conta continua valendo. Achado é um **apontamento derivado**, recalculado inteiro a cada rodada das regras: não existe "achado que não mudou", ele dispara com os dados de agora ou não dispara. Manter um achado "tratado" que já não dispara equivale a mostrar uma inconsistência que não existe mais, contrariando o propósito de sinalizar o que precisa de atenção. Decisão explícita do usuário, revertendo a escolha original de preservar tratativas.
@ -149,7 +163,11 @@ Uma observação registrada num mês reaparece na análise dos meses seguintes,
2. **O histórico cobre Balancete/D.R.E./Análise Vertical.** A justificativa de tratativa de um achado (`ContabilAchado.observacao_contador`) continua presa à apuração — e, como o achado é recriado a cada reprocessamento, ela nem sobrevive a isso. Só `ContabilObservacao` atravessa competências.
3. **`mostrar_ao_cliente` é sempre alternável**, inclusive numa observação já travada. O bloqueio protege texto, autor e data; mostrar ou não ao cliente é decisão editorial de cada relatório, e uma marcação errada precisa ser corrigível sem reescrever o histórico.
**Chave natural, nunca FK para a linha**: `"codigo|descricao"` no Balancete, `"descricao|nivel"` na DRE/Análise Vertical (`chave_conta()`/`chave_linha()` no model, `_contabil_chave_alvo()` na view, `dcChaveObsConta()` no JS — mesmo formato nos três). São exatamente as chaves que a sincronização do reprocessamento usa.
**Chave natural, nunca FK para a linha**: `"codigo|descricao"` no Balancete, `"caminho na árvore|nivel"` na DRE/Análise Vertical (ex.: `"(-) DESPESAS OPERACIONAIS > DESPESAS DE VENDAS > DESPESAS COM PESSOAL|2"`). A regra mora em `chaves.py` (Python puro) e é consumida por `chave_conta()`/`chave_linha()` no model, `_contabil_chave_alvo()` na view e `dcChaveObsConta()`/`dcChaveObsLinha()` no JS — mesmo formato nos três. São exatamente as chaves que a sincronização do reprocessamento usa.
> **O caminho na chave da DRE não é enfeite.** O mesmo rótulo aparece em ramos diferentes no mesmo nível ("DESPESAS COM PESSOAL" sob "DESPESAS DE VENDAS" e sob "DESPESAS ADMINISTRATIVAS"), e com a chave `(descricao, nivel)` a observação escrita numa vazava para a outra, além de o reprocessamento perder uma das duas linhas (ver "Reprocessar" acima). Mesma classe de problema que a descrição já resolvera no Balancete.
>
> Consequência prática: a chave de uma linha **não é calculável a partir da linha isolada**, só percorrendo a árvore. Por isso `_contabil_chave_alvo()` recebe a apuração inteira, `_contabil_arvore_contexto()` recebe uma lista de chaves alinhada por posição (não uma `chave_fn`), e o JS calcula as chaves por tabela em `dcAplicaChavesObs()` (no começo de `renderDre()`/`renderAnaliseVertical()`, não só ao abrir a apuração: marcar uma linha como validada troca o objeto pelo retorno da API). Ao mexer em qualquer um dos lados, os dois precisam continuar produzindo a mesma string.
> **A descrição faz parte da chave do Balancete, e isso não é redundância.** O Questor reaproveita o mesmo código de classificação para várias contas analíticas de mesma natureza — confirmado pelo usuário com 6 bancos diferentes (Banco do Brasil, Inter, Itaú, Mercado Pago, PagSeguro, Sicredi) sob o mesmo código de "Depósitos Bancários à Vista". Com a chave só por `codigo`, uma observação escrita num banco aparecia em todos os outros. Trade-off aceito: uma conta **renomeada** com o mesmo código vira uma conta "nova" no reprocessamento (a antiga é excluída, outra é criada) — o mesmo trade-off que a DRE já aceitava.
@ -474,7 +492,7 @@ Cor por sinal só onde é seguro: `sign` (ROA/ROE/EBIT/EBITDA — positivo verde
- **Códigos de classificação fixos** — se um cliente usar plano de contas com numeração diferente, indicadores e a regra de caixa saem errados **silenciosamente**. Revisar contra mais balancetes reais de empresas diferentes antes de confiar cegamente num valor exibido ao cliente.
- **Descrições coladas em PDF de fonte atípica** — não têm correção segura; a ferramenta avisa por badge. Valores monetários nunca são afetados.
- **Duas cópias mantidas à mão**: os SVGs de ícone (Python + JS) e a lógica de árvore/destaque (tela de revisão + relatório). 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.
- **Duas constantes conceituais duplicadas**: `PID_DC_NIVEL_ABERTO_PADRAO` (JS) e `_CONTABIL_NIVEL_ABERTO_PADRAO` (Python), hoje com papéis diferentes (sob demanda na revisão, padrão no relatório).
- **`total_achados_pendentes` gera N+1** na listagem de apurações.
- **Testar exclusão nesta ViewSet destrói arquivo de verdade**: `perform_destroy()` chama `instance.arquivo.delete(save=False)`, e apagar arquivo do storage **não é revertido** por `transaction.set_rollback(True)` — o padrão de teste usado no resto desta documentação protege só o banco. Validar o caminho "204" contra uma apuração real já custou o PDF anexado dela (o registro voltou pelo rollback, o arquivo não). Nenhum dado analítico depende desse arquivo (`arquivo` não é exposto em serializer nenhum e `reprocessar()` sempre grava um upload novo), mas um teste futuro precisa de apuração descartável ou storage isolado. Ver `[[feedback_rollback_nao_desfaz_arquivo]]` na memória.

View File

@ -8,7 +8,7 @@ Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil
## 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), `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 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).
- `ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical`/`ContabilAchado`/`ContabilObservacao`/`ContabilObservacaoEdicao` (`portal_api/models.py`).
- `dashboard-contabil.html` / `static/js/dashboard-contabil.js` / `static/css/dashboard-contabil.css`.
- `dashboard-contabil-relatorio.html` (relatório em si) / `portal_api/templatetags/contabil_extras.py` (filtros de formatação).

View File

@ -0,0 +1,91 @@
"""Chaves naturais de conta/linha do Relatório Contábil.
Python puro (sem ORM), como todo o resto deste pacote, porque a mesma regra
precisa servir a três consumidores que não podem divergir entre si: a
sincronização do reprocessamento (`_contabil_sincroniza_*()` em `views.py`),
o histórico de observações (`ContabilObservacao.alvo_chave`) e os mapas de
âncora do relatório HTML. Quando essas três definições de "é a mesma linha"
divergem, o sintoma é silencioso: linha duplicada que sobrevive ao
reprocessamento, ou observação que aparece numa conta que não é a dela.
**A chave da DRE/Análise Vertical inclui o caminho na árvore**, não só a
descrição e o nível: o mesmo rótulo aparece em ramos diferentes no mesmo
nível (caso real: "DESPESAS COM PESSOAL" sob "DESPESAS DE VENDAS" e sob
"DESPESAS ADMINISTRATIVAS"), e o par `(descricao, nivel)` sozinho não
distinguia os dois. Mesmo espírito da descrição ter entrado na chave do
Balancete quando se descobriu que o Questor reaproveita o mesmo código de
classificação entre contas analíticas irmãs.
"""
from typing import Protocol, Sequence
# Separador entre os rótulos dos grupos ancestrais dentro do caminho. Só
# precisa ser algo que não apareça numa descrição de conta do Questor; o
# `|` já é o separador entre caminho e nível.
SEPARADOR_CAMINHO = " > "
class LinhaComNivel(Protocol):
"""Qualquer linha de DRE/Análise Vertical — serve tanto para os models
(`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical`) quanto para as
dataclasses de extração (`modelos.LinhaDreExtraida`/
`LinhaAnaliseVerticalExtraida`), que não compartilham base nenhuma."""
descricao: str
nivel: int
def nivel_normalizado(nivel: int) -> int:
"""Nível nunca negativo — o parser deriva o nível da posição horizontal
do primeiro caractere (ver `parser.py`), então uma linha ligeiramente à
esquerda da raiz poderia sair em -1. `views.py` e `dashboard-contabil.js`
já normalizavam com `max(0, ...)` ao montar a árvore; a chave precisa da
mesma normalização para não depender do arredondamento."""
return max(0, nivel)
def caminhos_linhas(linhas: Sequence[LinhaComNivel]) -> list[str]:
"""Caminho de cada linha na árvore, alinhado por posição com `linhas`
(que precisa vir na ordem de leitura do PDF, `ordem` crescente).
O caminho é a descrição dos grupos ancestrais mais a da própria linha,
unidas por `SEPARADOR_CAMINHO` — a mesma pilha de níveis usada por
`regras._indices_descendentes_de_conta_redutora()`, e não comparação de
prefixo de texto. Um nível pulado (o PDF vai do 0 direto para o 2) deixa
um buraco na pilha, descartado do caminho: o que importa é a sequência de
ancestrais reais, não a profundidade numérica.
"""
caminhos: list[str] = []
pilha: list[str] = []
for linha in linhas:
nivel = nivel_normalizado(linha.nivel)
del pilha[nivel:]
while len(pilha) < nivel:
pilha.append("")
pilha.append(linha.descricao)
caminhos.append(SEPARADOR_CAMINHO.join(parte for parte in pilha if parte))
return caminhos
def chave_conta(codigo: str, descricao: str) -> str:
"""Chave natural de uma conta do Balancete. `codigo` sozinho **não** é
único (o Questor reaproveita a mesma classificação entre contas
analíticas de mesma natureza, ex. seis bancos diferentes sob o código de
"Depósitos Bancários à Vista"), por isso a descrição entra junto."""
return f"{codigo}|{descricao}"
def chave_linha(caminho: str, nivel: int) -> str:
"""Chave natural de uma linha da DRE/Análise Vertical, a partir do
caminho devolvido por `caminhos_linhas()`. O nível continua na chave
mesmo sendo quase sempre derivável do caminho: um mesmo rótulo pode
aparecer duas vezes no mesmo ramo em níveis diferentes."""
return f"{caminho}|{nivel_normalizado(nivel)}"
def chaves_linhas(linhas: Sequence[LinhaComNivel]) -> list[str]:
"""`chave_linha()` de cada linha, alinhada por posição com `linhas` — o
atalho que todos os chamadores usam, já que a chave de uma linha nunca é
calculável isoladamente, só no contexto da árvore inteira."""
caminhos = caminhos_linhas(linhas)
return [chave_linha(caminho, linha.nivel) for caminho, linha in zip(caminhos, linhas)]

View File

@ -321,6 +321,82 @@ def regra_variacao_atipica_dre(atual: ResultadoExtracao, historico: list[Snapsho
return achados
# Chave da única "regra" que não roda sobre o PDF, e sim sobre a diferença
# entre o que estava salvo e o que veio no reprocessamento (ver
# `achados_itens_removidos()` abaixo). Fica fora de `REGRAS` de propósito:
# `gera_achados()` só enxerga a extração atual, nunca o estado anterior.
REGRA_ITEM_REMOVIDO = "item_removido_reprocessamento"
ORIGEM_BALANCETE = "Balancete"
ORIGEM_DRE = "D.R.E."
ORIGEM_ANALISE_VERTICAL = "Análise Vertical"
# A mesma origem, com artigo, pra caber na frase do apontamento.
_ORIGEM_COM_ARTIGO = {
ORIGEM_BALANCETE: "do Balancete",
ORIGEM_DRE: "da D.R.E.",
ORIGEM_ANALISE_VERTICAL: "da Análise Vertical",
}
def _lista_origens(origens: list[str]) -> str:
"""`"da D.R.E. e da Análise Vertical"` — uma linha da DRE quase sempre
some das duas tabelas ao mesmo tempo (é a mesma árvore), e dois
apontamentos com o mesmo texto seriam ruído."""
partes = [_ORIGEM_COM_ARTIGO.get(origem, origem) for origem in origens]
if len(partes) == 1:
return partes[0]
return f"{', '.join(partes[:-1])} e {partes[-1]}"
def achados_itens_removidos(removidos: list[tuple[str, str]]) -> list[AchadoDetectado]:
"""Um apontamento por conta/linha que existia na versão anterior da
apuração e não veio no arquivo do reprocessamento.
`removidos` é uma lista de `(origem, rótulo)` — a origem é uma das
constantes `ORIGEM_*` e o rótulo já vem pronto de quem detectou a remoção
(`_contabil_sincroniza_*()` em `views.py`): `"1.01.01.001 CAIXA"` no
Balancete, o caminho na árvore na DRE/Análise Vertical. Rótulos iguais
viram **um** apontamento citando as duas tabelas: DRE e Análise Vertical
são a mesma árvore, então uma linha retirada do PDF some das duas.
Por que nasce aqui e não numa `regra_*`: as regras são funções puras
sobre a extração do PDF atual, e "sumiu" só é visível comparando com o
que estava salvo, que é justamente o que a sincronização do
reprocessamento faz. A construção do achado fica neste módulo mesmo
assim, junto de todos os outros textos/severidades, para não espalhar a
definição de apontamento por dois arquivos.
**O aviso vale para o reprocessamento em que a remoção aconteceu.** Como
todo achado é recriado do zero a cada reprocessamento e a linha já não
está no banco, um reprocessamento seguinte (com o mesmo arquivo sem ela)
não repete o aviso: naquele ponto não há mais nada sendo removido.
"""
agrupados: dict[str, list[str]] = {}
for origem, rotulo in removidos:
origens = agrupados.setdefault(rotulo, [])
if origem not in origens:
origens.append(origem)
achados: list[AchadoDetectado] = []
for rotulo, origens in agrupados.items():
eh_conta = origens == [ORIGEM_BALANCETE]
achados.append(
AchadoDetectado(
regra=REGRA_ITEM_REMOVIDO,
severidade=SEVERIDADE_MEDIA,
titulo=("Conta removida no reprocessamento" if eh_conta else "Linha removida no reprocessamento"),
mensagem=(
f'{"A conta" if eh_conta else "A linha"} "{rotulo}" constava na versão anterior '
f"{_lista_origens(origens)} desta apuração e não veio no arquivo reprocessado, por isso "
"foi excluída. Confira se a exclusão era esperada. As observações registradas nela "
"continuam no histórico da empresa."
),
)
)
return achados
REGRAS = [
regra_balanceamento_ativo_passivo,
regra_debito_credito_divergente,

View File

@ -0,0 +1,123 @@
# Generated manually on 2026-09-22
#
# Bug real: a chave natural de uma linha da DRE/Análise Vertical era o par
# `(descricao, nivel)`, e o mesmo rótulo aparece em ramos diferentes no mesmo
# nível — caso encontrado em produção: "DESPESAS COM PESSOAL" existindo sob
# "DESPESAS DE VENDAS" e sob "DESPESAS ADMINISTRATIVAS". Duas consequências:
# a observação escrita numa delas aparecia na outra, e a sincronização do
# reprocessamento perdia uma das duas linhas (nunca atualizada, nunca
# excluída, congelada com o valor da importação original). A chave passou a
# incluir o caminho na árvore (ver `dashboard_contabil/chaves.py`), mesmo
# espírito da migração 0073, que acrescentou a descrição à chave do Balancete.
#
# Esta migração reescreve `alvo_chave` das observações já gravadas de
# `alvo_tipo` "dre"/"analise_vertical". Diferente da 0073, o valor novo não é
# derivável do próprio registro: o caminho depende dos grupos acima da linha,
# então é reconstruído a partir das linhas da apuração de origem (ou, se ela
# já tiver sido excluída, da apuração mais recente da mesma empresa que ainda
# tenha essa linha). Observação cuja linha não for encontrada em nenhuma das
# duas fica com a chave antiga: ela continua listada no resumo da aba e no
# relatório pelo `alvo_rotulo`, só não casa com nenhuma linha — exatamente o
# que já acontece com uma conta que saiu do plano.
#
# A lógica de caminho está duplicada aqui de propósito: uma migração não pode
# depender de código de aplicação que muda depois dela.
from django.db import migrations, models
SEPARADOR_CAMINHO = " > "
def _caminhos(linhas):
"""Caminho de cada linha, alinhado por posição — cópia de
`dashboard_contabil.chaves.caminhos_linhas()`."""
caminhos = []
pilha = []
for linha in linhas:
nivel = max(0, linha.nivel)
del pilha[nivel:]
while len(pilha) < nivel:
pilha.append("")
pilha.append(linha.descricao)
caminhos.append(SEPARADOR_CAMINHO.join(parte for parte in pilha if parte))
return caminhos
def _linhas_da_apuracao(apuracao, alvo_tipo):
relacao = apuracao.linhas_dre if alvo_tipo == "dre" else apuracao.linhas_analise_vertical
return list(relacao.order_by("ordem"))
def _chaves_novas_por_chave_antiga(linhas):
"""`{"descricao|nivel": "caminho|nivel"}` — a primeira ocorrência vence
quando a chave antiga é ambígua, que é justamente o defeito corrigido
aqui: não há como saber a qual das linhas homônimas a observação se
referia, e a primeira é a que o código antigo também teria casado ao
montar o índice do frontend."""
mapa = {}
for linha, caminho in zip(linhas, _caminhos(linhas)):
nivel = max(0, linha.nivel)
antiga = f"{linha.descricao}|{linha.nivel}"
mapa.setdefault(antiga, f"{caminho}|{nivel}")
return mapa
def recalcula_alvo_chave(apps, schema_editor):
ContabilApuracao = apps.get_model("portal_api", "ContabilApuracao")
ContabilObservacao = apps.get_model("portal_api", "ContabilObservacao")
cache = {}
def mapa_de(apuracao, alvo_tipo):
chave_cache = (apuracao.id, alvo_tipo)
if chave_cache not in cache:
cache[chave_cache] = _chaves_novas_por_chave_antiga(_linhas_da_apuracao(apuracao, alvo_tipo))
return cache[chave_cache]
for observacao in ContabilObservacao.objects.filter(alvo_tipo__in=["dre", "analise_vertical"]):
candidatas = []
if observacao.apuracao_origem_id:
candidatas.append(ContabilApuracao.objects.filter(pk=observacao.apuracao_origem_id).first())
candidatas.extend(
ContabilApuracao.objects.filter(codigo_empresa=observacao.codigo_empresa).order_by("-competencia")
)
for apuracao in candidatas:
if apuracao is None:
continue
nova_chave = mapa_de(apuracao, observacao.alvo_tipo).get(observacao.alvo_chave)
if not nova_chave:
continue
if nova_chave != observacao.alvo_chave:
observacao.alvo_chave = nova_chave
observacao.save(update_fields=["alvo_chave"])
break
def reverte_alvo_chave(apps, schema_editor):
"""Volta a chave para `descricao|nivel` — o último segmento do caminho é
a descrição da própria linha."""
ContabilObservacao = apps.get_model("portal_api", "ContabilObservacao")
for observacao in ContabilObservacao.objects.filter(alvo_tipo__in=["dre", "analise_vertical"]):
caminho, _, nivel = observacao.alvo_chave.rpartition("|")
descricao = caminho.rsplit(SEPARADOR_CAMINHO, 1)[-1]
antiga = f"{descricao}|{nivel}"
if antiga != observacao.alvo_chave:
observacao.alvo_chave = antiga
observacao.save(update_fields=["alvo_chave"])
class Migration(migrations.Migration):
dependencies = [
("portal_api", "0077_backfill_achado_linha_analise_vertical"),
]
operations = [
# O caminho inteiro é bem mais longo que a descrição isolada.
migrations.AlterField(
model_name="contabilobservacao",
name="alvo_chave",
field=models.CharField(max_length=1000, verbose_name="Chave natural do alvo"),
),
migrations.RunPython(recalcula_alvo_chave, reverte_alvo_chave),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 6.0.7 on 2026-09-22 14:50
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0078_contabil_observacao_chave_caminho_arvore'),
]
operations = [
migrations.AlterModelOptions(
name='contabilconta',
options={'ordering': ['ordem', 'id'], 'verbose_name': 'Conta do Balancete (Relatório Contábil)', 'verbose_name_plural': 'Contas do Balancete (Relatório Contábil)'},
),
migrations.AlterModelOptions(
name='contabillinhaanalisevertical',
options={'ordering': ['ordem', 'id'], 'verbose_name': 'Linha da Análise Vertical (Relatório Contábil)', 'verbose_name_plural': 'Linhas da Análise Vertical (Relatório Contábil)'},
),
migrations.AlterModelOptions(
name='contabillinhadre',
options={'ordering': ['ordem', 'id'], 'verbose_name': 'Linha da DRE (Relatório Contábil)', 'verbose_name_plural': 'Linhas da DRE (Relatório Contábil)'},
),
]

View File

@ -12,6 +12,7 @@ from django.utils import timezone
from .custo_contratacao import tabelas as tabelas_custo_contratacao
from .custo_contratacao.calculo import ParametrosFiscais
from .dashboard_contabil import chaves as chaves_contabil
LINK_FERRAMENTA_ICONE_MAX_BYTES = 2 * 1024 * 1024
ACESSO_GERAL_OBSERVACOES_MAX_CHARS = 2_000_000
@ -2148,7 +2149,12 @@ class ContabilConta(models.Model):
class Meta:
verbose_name = "Conta do Balancete (Relatório Contábil)"
verbose_name_plural = "Contas do Balancete (Relatório Contábil)"
ordering = ["ordem"]
# `id` desempata: `ordem` sozinha deixa a ordenação indefinida quando
# duas linhas empatam, e a árvore (nível de cada linha em relação à
# anterior) e a chave natural da DRE/Análise Vertical dependem da
# ordem de leitura. Empate não deveria acontecer, mas quando acontece
# o resultado precisa ser o mesmo em toda consulta.
ordering = ["ordem", "id"]
def __str__(self) -> str:
return f"{self.codigo} {self.descricao}"
@ -2179,7 +2185,8 @@ class ContabilLinhaDre(models.Model):
class Meta:
verbose_name = "Linha da DRE (Relatório Contábil)"
verbose_name_plural = "Linhas da DRE (Relatório Contábil)"
ordering = ["ordem"]
# Ver a nota em `ContabilConta.Meta`.
ordering = ["ordem", "id"]
def __str__(self) -> str:
return f"{self.descricao}"
@ -2214,7 +2221,8 @@ class ContabilLinhaAnaliseVertical(models.Model):
class Meta:
verbose_name = "Linha da Análise Vertical (Relatório Contábil)"
verbose_name_plural = "Linhas da Análise Vertical (Relatório Contábil)"
ordering = ["ordem"]
# Ver a nota em `ContabilConta.Meta`.
ordering = ["ordem", "id"]
def __str__(self) -> str:
return f"{self.descricao}"
@ -2310,7 +2318,8 @@ class ContabilObservacao(models.Model):
apuração específica (uma linha é recriada/ressincronizada a cada
apuração/reprocessamento): `"codigo|descricao"` no Balancete (ver
`chave_conta()` — `codigo` de classificação sozinho não é único, ver
abaixo) e `"descricao|nivel"` na DRE/Análise Vertical — as mesmas chaves
abaixo) e `"caminho na árvore|nivel"` na DRE/Análise Vertical (ver
`chave_linha()`) — as mesmas chaves
já usadas por `_contabil_sincroniza_*()` em views.py e pelo histórico de variação
em `regras.py`. Efeito colateral bem-vindo: reprocessar uma apuração não
toca em observação nenhuma, já que elas não moram mais na linha que é
@ -2343,7 +2352,12 @@ class ContabilObservacao(models.Model):
codigo_empresa = models.CharField("Código da empresa", max_length=20)
alvo_tipo = models.CharField("Tipo do alvo", max_length=20, choices=ALVO_CHOICES)
alvo_chave = models.CharField("Chave natural do alvo", max_length=320)
# 320 bastava quando a chave da DRE era `descricao|nivel`; com o caminho
# na árvore inteiro (ver `chave_linha()`) uma linha funda soma a descrição
# de todos os grupos acima dela, então o limite subiu com folga. Não pode
# truncar: chave truncada volta a confundir duas linhas diferentes, que é
# exatamente o defeito que o caminho veio corrigir.
alvo_chave = models.CharField("Chave natural do alvo", max_length=1000)
# Descrição da conta/linha no momento em que a observação foi escrita —
# só pra exibir o histórico quando aquela conta não existir mais na
# apuração aberta (plano de contas mudou, linha saiu do relatório).
@ -2400,14 +2414,18 @@ class ContabilObservacao(models.Model):
"vazar" pra todas as outras que compartilham a classificação.
`descricao` desambigua, mesmo espírito de `chave_linha()` pra
DRE/Análise Vertical."""
return f"{codigo}|{descricao}"
return chaves_contabil.chave_conta(codigo, descricao)
@staticmethod
def chave_linha(descricao: str, nivel: int) -> str:
"""Chave natural de uma linha da DRE/Análise Vertical — o par
`(descricao, nivel)` de `_contabil_sincroniza_linhas_dre()`, que
desambigua descrições repetidas em ramos diferentes da árvore."""
return f"{descricao}|{nivel}"
def chave_linha(caminho: str, nivel: int) -> str:
"""Chave natural de uma linha da DRE/Análise Vertical, a partir do
**caminho na árvore** (`chaves_contabil.caminhos_linhas()`), não da
descrição isolada: o mesmo rótulo aparece em ramos diferentes no
mesmo nível (caso real: "DESPESAS COM PESSOAL" sob "DESPESAS DE
VENDAS" e sob "DESPESAS ADMINISTRATIVAS"). Como a chave nunca é
calculável a partir de uma linha isolada, o chamador normalmente usa
`chaves_contabil.chaves_linhas(linhas)` sobre a lista inteira."""
return chaves_contabil.chave_linha(caminho, nivel)
@classmethod
def vigentes_para(cls, codigo_empresa: str, competencia: date) -> models.QuerySet["ContabilObservacao"]:

View File

@ -38,11 +38,13 @@ from rest_framework.serializers import ModelSerializer
from . import catalogo
from .custo_contratacao.calculo import calcula_custo_empregado
from .custo_contratacao.pdf import gera_pdf_simulacao
from .dashboard_contabil import chaves as dashboard_contabil_chaves
from .dashboard_contabil import exportacao as dashboard_contabil_exportacao
from .dashboard_contabil import formula as dashboard_contabil_formula
from .dashboard_contabil import indicadores as dashboard_contabil_indicadores
from .dashboard_contabil import modelos as dashboard_contabil_modelos
from .dashboard_contabil import pipeline as dashboard_contabil_pipeline
from .dashboard_contabil import regras as dashboard_contabil_regras
from .dashboard_contabil import resumo_pdf as dashboard_contabil_resumo_pdf
from .templatetags import contabil_extras as dashboard_contabil_extras
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
@ -3440,8 +3442,8 @@ class _ContabilDadosIndicadores:
def _contabil_coleta_dados_indicadores(apuracao: ContabilApuracao) -> _ContabilDadosIndicadores:
contas = list(apuracao.contas.order_by("ordem"))
linhas_dre = list(apuracao.linhas_dre.order_by("ordem"))
contas = list(apuracao.contas.order_by("ordem", "id"))
linhas_dre = list(apuracao.linhas_dre.order_by("ordem", "id"))
contas_atuais = {conta.codigo: conta.saldo_atual for conta in contas}
dre_atual = {linha.descricao: linha.valor for linha in linhas_dre}
resultado_liquido = linhas_dre[-1].valor if linhas_dre else Decimal(0)
@ -3772,7 +3774,7 @@ def _contabil_arvore_contexto(
nivel_fn: Callable[[Any], int],
px_por_nivel: int,
observacoes_por_chave: dict[str, list[ContabilObservacao]] | None = None,
chave_fn: Callable[[Any], str] | None = None,
chaves: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Monta o contexto de uma árvore recolhível (Balancete/DRE) pro
relatório "Gerar Dashboard" — mesmo algoritmo de `dcContaNivel()`/
@ -3783,17 +3785,20 @@ def _contabil_arvore_contexto(
linhas já prontas (ver `dashboard-contabil-relatorio.html`). `colapsado_padrao`
marca os itens que devem nascer recolhidos (ver `_CONTABIL_NIVEL_ABERTO_PADRAO`).
`observacoes_por_chave`+`chave_fn` (opcionais, usados pelo relatório)
`observacoes_por_chave`+`chaves` (opcionais, usados pelo relatório)
penduram em cada item as observações vigentes daquela conta/linha que o
contador marcou pra mostrar ao cliente — casadas pela chave natural do
histórico (`ContabilObservacao.alvo_chave`), não por id de linha."""
histórico (`ContabilObservacao.alvo_chave`), não por id de linha. `chaves`
é uma **lista alinhada por posição** com `itens`, e não uma função por
item, porque a chave de uma linha da DRE/Análise Vertical depende dos
grupos acima dela (ver `dashboard_contabil.chaves`)."""
niveis = [nivel_fn(item) for item in itens]
contexto = []
for i, item in enumerate(itens):
tem_filhos = i + 1 < len(itens) and niveis[i + 1] > niveis[i]
observacoes: list[ContabilObservacao] = []
if observacoes_por_chave is not None and chave_fn is not None:
observacoes = observacoes_por_chave.get(chave_fn(item), [])
if observacoes_por_chave is not None and chaves is not None:
observacoes = observacoes_por_chave.get(chaves[i], [])
contexto.append(
{
chave: item,
@ -3807,14 +3812,32 @@ def _contabil_arvore_contexto(
return contexto
def _contabil_chave_alvo(alvo_tipo: str, alvo: Any) -> str:
def _contabil_chave_alvo(apuracao: ContabilApuracao, alvo_tipo: str, alvo: Any) -> str:
"""Chave natural de uma conta/linha pro histórico de observações — as
mesmas usadas por `_contabil_sincroniza_*()` no reprocessamento
(`(codigo, descricao)` no Balancete, `(descricao, nivel)` na DRE/Análise
Vertical), pra uma observação seguir a mesma conta de uma competência pra
outra."""
(`(codigo, descricao)` no Balancete, caminho na árvore + nível na
DRE/Análise Vertical), pra uma observação seguir a mesma conta de uma
competência pra outra.
Precisa da `apuracao` inteira, e não só do alvo, porque a chave de uma
linha da DRE/Análise Vertical depende dos grupos acima dela (ver
`dashboard_contabil.chaves.caminhos_linhas()`) — uma linha isolada não
sabe em que ramo está."""
if alvo_tipo == ContabilObservacao.ALVO_CONTA:
return ContabilObservacao.chave_conta(alvo.codigo, alvo.descricao)
irmas = (
apuracao.linhas_dre.order_by("ordem", "id")
if alvo_tipo == ContabilObservacao.ALVO_DRE
else apuracao.linhas_analise_vertical.order_by("ordem", "id")
)
linhas = list(irmas)
chaves = dashboard_contabil_chaves.chaves_linhas(linhas)
for linha, chave in zip(linhas, chaves):
if linha.pk == alvo.pk:
return chave
# Inalcançável na prática (`_resolve_alvo()` já garantiu que o alvo é
# desta apuração), mas um fallback é melhor do que estourar na criação da
# observação.
return ContabilObservacao.chave_linha(alvo.descricao, alvo.nivel)
@ -3834,9 +3857,63 @@ def _contabil_observacoes_por_chave(apuracao: ContabilApuracao, alvo_tipo: str)
return agrupadas
def _contabil_agrupa_por_chave(pares: Iterable[tuple[str, Any]]) -> dict[str, list[Any]]:
"""Agrupa conta/linha já salva por chave natural, em **fila** e não em
registro único.
Bug real: estas três funções montavam `{chave: linha}` por dict
comprehension. Quando duas linhas produziam a mesma chave, o dict ficava
só com a última, e a outra virava um fantasma — nunca atualizada (não
estava no dict) e nunca excluída (o laço final varre o dict, não a
tabela), então sobrevivia a todo reprocessamento com o valor congelado da
primeira importação, sem nem o alerta de `alterada_reprocessamento`.
Encontrado em produção com "DESPESAS COM PESSOAL" repetida em dois ramos
da DRE: removida de um deles no PDF novo, a linha continuava na tela.
A chave da DRE/Análise Vertical passou a incluir o caminho na árvore (ver
`dashboard_contabil.chaves`), o que remove a colisão daquele caso, mas a
fila continua sendo a forma certa de montar isto: duas linhas irmãs
idênticas no mesmo ramo ainda colidem, e aí o certo é casar uma com cada
ocorrência do PDF novo (na ordem de leitura) e excluir o que sobrar.
"""
filas: dict[str, list[Any]] = {}
for chave, item in pares:
filas.setdefault(chave, []).append(item)
return filas
def _contabil_proxima_da_fila(filas: dict[str, list[Any]], chave: str) -> Any | None:
"""Próxima conta/linha salva com esta chave, consumindo-a da fila — o que
sobrar nas filas no fim da sincronização é o que não veio no PDF novo."""
fila = filas.get(chave)
return fila.pop(0) if fila else None
def _contabil_exclui_sobras(filas: dict[str, list[Any]]) -> list[tuple[str, Any]]:
"""Exclui toda conta/linha que nenhuma linha do PDF novo reclamou, e
devolve os pares `(chave, registro excluído)` — é a partir daí que o
reprocessamento monta o aviso de "conta removida" na aba Observações (ver
`regras.achados_itens_removidos()`)."""
removidos: list[tuple[str, Any]] = []
for chave, fila in filas.items():
for antiga in fila:
removidos.append((chave, antiga))
antiga.delete()
return removidos
def _contabil_rotulo_linha_removida(chave: str) -> str:
"""Rótulo de uma linha da DRE/Análise Vertical que sumiu, a partir da
chave natural: o caminho na árvore sem o nível (`"A > B > C|2"` vira
`"A > B > C"`). O caminho inteiro, e não só a descrição, porque o mesmo
rótulo aparece em ramos diferentes — sem o ramo, o aviso não diria qual
das duas linhas saiu."""
return chave.rpartition("|")[0] or chave
def _contabil_sincroniza_contas(
apuracao: ContabilApuracao, contas_extraidas: list[dashboard_contabil_modelos.LinhaBalanceteExtraida]
) -> None:
) -> list[tuple[str, str]]:
"""Resincroniza `ContabilConta` a partir de um reprocessamento
(`ContabilApuracaoViewSet.reprocessar()`) — casa pelo par
`(codigo, descricao)` (chave natural já usada pelo histórico de
@ -3851,18 +3928,20 @@ def _contabil_sincroniza_contas(
tooltip do badge no frontend). Uma conta renomeada (mesmo código, outra
descrição) é tratada como uma conta diferente — a antiga é excluída e uma
nova é criada, mesmo trade-off que `_contabil_sincroniza_linhas_dre()`
já aceita pra `(descricao, nivel)`. Observação nunca é afetada por aqui:
já aceita pro caminho na árvore. Observação nunca é afetada por aqui:
ela não mora mais na linha, e sim em `ContabilObservacao` (histórico por
empresa+conta)."""
antigas = {(conta.codigo, conta.descricao): conta for conta in apuracao.contas.all()}
vistos: set[tuple[str, str]] = set()
empresa+conta). Devolve `(origem, rótulo)` de cada conta excluída, pro
reprocessamento virar apontamento na aba Observações."""
antigas = _contabil_agrupa_por_chave(
(ContabilObservacao.chave_conta(conta.codigo, conta.descricao), conta)
for conta in apuracao.contas.order_by("ordem", "id")
)
for indice, extraida in enumerate(contas_extraidas):
tipo = extraida.tipo or "A"
chave = (extraida.codigo, extraida.descricao)
antiga = antigas.get(chave)
chave = ContabilObservacao.chave_conta(extraida.codigo, extraida.descricao)
antiga = _contabil_proxima_da_fila(antigas, chave)
if antiga is not None:
vistos.add(chave)
alterou = (
antiga.tipo != tipo
or antiga.saldo_anterior != extraida.saldo_anterior
@ -3895,27 +3974,28 @@ def _contabil_sincroniza_contas(
saldo_atual=extraida.saldo_atual,
)
for chave, antiga in antigas.items():
if chave not in vistos:
antiga.delete()
return [
(dashboard_contabil_regras.ORIGEM_BALANCETE, f"{conta.codigo} {conta.descricao}")
for _, conta in _contabil_exclui_sobras(antigas)
]
def _contabil_sincroniza_linhas_dre(
apuracao: ContabilApuracao, linhas_extraidas: list[dashboard_contabil_modelos.LinhaDreExtraida]
) -> None:
) -> list[tuple[str, str]]:
"""Mesmo espírito de `_contabil_sincroniza_contas()`, sobre
`ContabilLinhaDre` — sem código de classificação, a chave natural é
`(descricao, nivel)` (o par que já desambigua a maioria das descrições
repetidas em ramos diferentes da árvore, ex. "COMISSÕES SOBRE VENDAS"
aparecendo em mais de um nível)."""
antigas = {(linha.descricao, linha.nivel): linha for linha in apuracao.linhas_dre.all()}
vistos: set[tuple[str, int]] = set()
`ContabilLinhaDre` — sem código de classificação, a chave natural é o
**caminho na árvore** mais o nível (ver `dashboard_contabil.chaves`), que
é o que distingue o mesmo rótulo repetido em ramos diferentes no mesmo
nível ("DESPESAS COM PESSOAL" sob Vendas e sob Administrativas). Devolve
`(origem, rótulo)` de cada linha excluída, como `_contabil_sincroniza_contas()`."""
salvas = list(apuracao.linhas_dre.order_by("ordem", "id"))
antigas = _contabil_agrupa_por_chave(zip(dashboard_contabil_chaves.chaves_linhas(salvas), salvas))
chaves_extraidas = dashboard_contabil_chaves.chaves_linhas(linhas_extraidas)
for linha in linhas_extraidas:
chave = (linha.descricao, linha.nivel)
antiga = antigas.get(chave)
for chave, linha in zip(chaves_extraidas, linhas_extraidas):
antiga = _contabil_proxima_da_fila(antigas, chave)
if antiga is not None:
vistos.add(chave)
alterou = antiga.totalizador != linha.totalizador or antiga.valor != linha.valor
antiga.valor_anterior_reprocessamento = antiga.valor if alterou else None
antiga.ordem = linha.ordem
@ -3934,9 +4014,10 @@ def _contabil_sincroniza_linhas_dre(
totalizador=linha.totalizador,
)
for chave, antiga in antigas.items():
if chave not in vistos:
antiga.delete()
return [
(dashboard_contabil_regras.ORIGEM_DRE, _contabil_rotulo_linha_removida(chave))
for chave, _ in _contabil_exclui_sobras(antigas)
]
def _contabil_valores_analise_vertical_iguais(
@ -3952,20 +4033,20 @@ def _contabil_valores_analise_vertical_iguais(
def _contabil_sincroniza_linhas_analise_vertical(
apuracao: ContabilApuracao, linhas_extraidas: list[dashboard_contabil_modelos.LinhaAnaliseVerticalExtraida]
) -> None:
) -> list[tuple[str, str]]:
"""Mesmo espírito de `_contabil_sincroniza_linhas_dre()`, sobre
`ContabilLinhaAnaliseVertical` — além de `totalizador`, compara a lista
inteira de `valores` (convertendo de volta pra `Decimal`, já que é
gravada como texto no JSONField)."""
antigas = {(linha.descricao, linha.nivel): linha for linha in apuracao.linhas_analise_vertical.all()}
vistos: set[tuple[str, int]] = set()
gravada como texto no JSONField). Mesmo retorno de `(origem, rótulo)` das
linhas excluídas."""
salvas = list(apuracao.linhas_analise_vertical.order_by("ordem", "id"))
antigas = _contabil_agrupa_por_chave(zip(dashboard_contabil_chaves.chaves_linhas(salvas), salvas))
chaves_extraidas = dashboard_contabil_chaves.chaves_linhas(linhas_extraidas)
for linha in linhas_extraidas:
chave = (linha.descricao, linha.nivel)
antiga = antigas.get(chave)
for chave, linha in zip(chaves_extraidas, linhas_extraidas):
antiga = _contabil_proxima_da_fila(antigas, chave)
novos_valores = [{"valor": str(v.valor), "percentual": str(v.percentual)} for v in linha.valores]
if antiga is not None:
vistos.add(chave)
alterou = antiga.totalizador != linha.totalizador or not _contabil_valores_analise_vertical_iguais(
antiga.valores, linha.valores
)
@ -3986,9 +4067,10 @@ def _contabil_sincroniza_linhas_analise_vertical(
valores=novos_valores,
)
for chave, antiga in antigas.items():
if chave not in vistos:
antiga.delete()
return [
(dashboard_contabil_regras.ORIGEM_ANALISE_VERTICAL, _contabil_rotulo_linha_removida(chave))
for chave, _ in _contabil_exclui_sobras(antigas)
]
def _contabil_recria_achados(
@ -3996,8 +4078,10 @@ def _contabil_recria_achados(
) -> None:
"""Recria do zero todo `ContabilAchado` de um reprocessamento — apaga
**todos** os achados existentes da apuração e recria a partir do motor de
regras rodado sobre o PDF novo, mesmo `bulk_create` de `create()` (ver
acima). Decisão revisada explicitamente pelo usuário (substitui a
regras rodado sobre o PDF novo (mais os avisos de conta/linha removida,
que o chamador junta em `achados_detectados` — ver
`regras.achados_itens_removidos()`), mesmo `bulk_create` de `create()`
(ver acima). Decisão revisada explicitamente pelo usuário (substitui a
decisão anterior de preservar `status`/`observacao_contador`/`tratado_por`/
`tratado_em`/`oculto_no_relatorio` — ver histórico no CHANGELOG.md do
pacote): um apontamento automático que não dispara mais com os dados
@ -4296,10 +4380,22 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
apuracao.save()
novo_arquivo_salvo = True
_contabil_sincroniza_contas(apuracao, resultado.extracao.contas)
_contabil_sincroniza_linhas_dre(apuracao, resultado.extracao.linhas_dre)
_contabil_sincroniza_linhas_analise_vertical(apuracao, resultado.extracao.linhas_analise_vertical)
_contabil_recria_achados(apuracao, resultado.achados)
# Conta/linha que sumiu do arquivo novo vira apontamento na
# aba Observações (pedido explícito do usuário): a exclusão
# em si é silenciosa, e uma linha desaparecer entre um
# arquivo e outro é exatamente o tipo de mudança que o
# contador precisa conferir. Os três sincronizadores
# devolvem `(origem, rótulo)` do que excluíram.
removidos = [
*_contabil_sincroniza_contas(apuracao, resultado.extracao.contas),
*_contabil_sincroniza_linhas_dre(apuracao, resultado.extracao.linhas_dre),
*_contabil_sincroniza_linhas_analise_vertical(
apuracao, resultado.extracao.linhas_analise_vertical
),
]
_contabil_recria_achados(
apuracao, resultado.achados + dashboard_contabil_regras.achados_itens_removidos(removidos)
)
ContabilApuracaoReprocessamento.objects.create(apuracao=apuracao, reprocessado_por=request.user)
except Exception:
if novo_arquivo_salvo:
@ -4364,9 +4460,9 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
aba (`window.open`), navegação de verdade, não um blob — precisava
ser GET pra isso funcionar (ver `dashboard-contabil.js`)."""
apuracao = self.get_object()
contas = list(apuracao.contas.order_by("ordem"))
linhas_dre = list(apuracao.linhas_dre.order_by("ordem"))
linhas_analise_vertical = list(apuracao.linhas_analise_vertical.order_by("ordem"))
contas = list(apuracao.contas.order_by("ordem", "id"))
linhas_dre = list(apuracao.linhas_dre.order_by("ordem", "id"))
linhas_analise_vertical = list(apuracao.linhas_analise_vertical.order_by("ordem", "id"))
dados_resumo = _contabil_dados_resumo(apuracao)
observacoes_visiveis = dados_resumo["observacoes_visiveis"]
@ -4395,11 +4491,12 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
observacao.ancora = f"{prefixo}-{alvo_id}" if alvo_id is not None else None
return observacoes
mapa_conta_por_chave = {ContabilObservacao.chave_conta(c.codigo, c.descricao): c.id for c in contas}
mapa_dre_por_chave = {ContabilObservacao.chave_linha(l.descricao, l.nivel): l.id for l in linhas_dre}
mapa_av_por_chave = {
ContabilObservacao.chave_linha(l.descricao, l.nivel): l.id for l in linhas_analise_vertical
}
chaves_contas = [ContabilObservacao.chave_conta(c.codigo, c.descricao) for c in contas]
chaves_dre = dashboard_contabil_chaves.chaves_linhas(linhas_dre)
chaves_av = dashboard_contabil_chaves.chaves_linhas(linhas_analise_vertical)
mapa_conta_por_chave = dict(zip(chaves_contas, (c.id for c in contas)))
mapa_dre_por_chave = dict(zip(chaves_dre, (l.id for l in linhas_dre)))
mapa_av_por_chave = dict(zip(chaves_av, (l.id for l in linhas_analise_vertical)))
contexto = {
"apuracao": apuracao,
@ -4410,7 +4507,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
lambda c: c.codigo.count("."),
18,
observacoes_por_tipo[ContabilObservacao.ALVO_CONTA],
lambda c: ContabilObservacao.chave_conta(c.codigo, c.descricao),
chaves_contas,
),
"linhas_dre": _contabil_arvore_contexto(
linhas_dre,
@ -4418,7 +4515,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
lambda l: max(0, l.nivel),
16,
observacoes_por_tipo[ContabilObservacao.ALVO_DRE],
lambda l: ContabilObservacao.chave_linha(l.descricao, l.nivel),
chaves_dre,
),
"analise_vertical_meses": apuracao.analise_vertical_meses,
"linhas_analise_vertical": _contabil_arvore_contexto(
@ -4427,7 +4524,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
lambda l: max(0, l.nivel),
16,
observacoes_por_tipo[ContabilObservacao.ALVO_ANALISE_VERTICAL],
lambda l: ContabilObservacao.chave_linha(l.descricao, l.nivel),
chaves_av,
),
"observacoes_contas": _com_ancora(
[observacao for observacao in observacoes_visiveis if observacao.alvo_tipo == ContabilObservacao.ALVO_CONTA],
@ -4612,7 +4709,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
raise ValidationError({"parte": "Informe 'balancete' ou 'dre'."})
if parte == "balancete":
contas = list(apuracao.contas.order_by("ordem"))
contas = list(apuracao.contas.order_by("ordem", "id"))
conteudo = dashboard_contabil_exportacao.gera_xlsx_balancete(
apuracao.nome_empresa,
apuracao.cnpj,
@ -4633,7 +4730,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
)
nome_arquivo = f"balancete_{apuracao.codigo_empresa}_{apuracao.competencia:%m-%Y}.xlsx"
else:
linhas_dre = list(apuracao.linhas_dre.order_by("ordem"))
linhas_dre = list(apuracao.linhas_dre.order_by("ordem", "id"))
conteudo = dashboard_contabil_exportacao.gera_xlsx_dre(
apuracao.nome_empresa,
apuracao.cnpj,
@ -4811,7 +4908,7 @@ class ContabilObservacaoViewSet(viewsets.ModelViewSet):
observacao = ContabilObservacao.objects.create(
codigo_empresa=apuracao.codigo_empresa,
alvo_tipo=dados["alvo_tipo"],
alvo_chave=_contabil_chave_alvo(dados["alvo_tipo"], alvo),
alvo_chave=_contabil_chave_alvo(apuracao, dados["alvo_tipo"], alvo),
alvo_rotulo=alvo.descricao,
apuracao_origem=apuracao,
competencia_origem=apuracao.competencia,

View File

@ -10,7 +10,9 @@ const PID_DC_SEVERIDADE_CORES = { alta: "var(--danger)", media: "var(--gold)", b
// Mesmas 9 regras/chaves de regras.py (REGRAS) — enumeradas aqui pra sempre
// mostrar as 9 categorias no resumo, mesmo as que não geraram achado nesta
// apuração (mesmo espírito do "Nenhum registro encontrado" do sistema externo
// que inspirou esta visão).
// que inspirou esta visão). A 10ª (`item_removido_reprocessamento`) não é uma
// regra sobre o PDF: nasce na sincronização do reprocessamento, quando uma
// conta/linha some do arquivo novo (ver regras.achados_itens_removidos()).
const PID_DC_REGRAS = [
{ chave: "balanceamento_ativo_passivo", label: "Balanceamento Ativo x Passivo" },
{ chave: "debito_credito_divergente", label: "Débito x Crédito" },
@ -21,6 +23,7 @@ const PID_DC_REGRAS = [
{ chave: "conta_deveria_zerar", label: "Contas que Deveriam Zerar" },
{ chave: "descricao_generica", label: "Descrição Genérica" },
{ chave: "variacao_atipica_dre", label: "Variação Atípica na DRE" },
{ chave: "item_removido_reprocessamento", label: "Contas e Linhas Removidas" },
];
// Agrupamento temático das 9 regras acima pro resumo da aba Observações —
@ -47,6 +50,15 @@ const PID_DC_GRUPOS = [
label: "Variações e Indicadores",
regras: ["variacao_atipica_dre"],
},
// `somenteComAchados`: só aparece na apuração em que algo foi de fato
// removido — um card fixo em "0" em toda análise nunca reprocessada seria
// ruído, diferente das regras acima, cujo "0" é a informação de que aquela
// checagem rodou e passou.
{
label: "Mudanças no Reprocessamento",
regras: ["item_removido_reprocessamento"],
somenteComAchados: true,
},
];
// Paleta de 10 cores distintas reaproveitando os tokens *-rgb já existentes
@ -1217,6 +1229,7 @@ document.addEventListener("DOMContentLoaded", async () => {
PID_DC_GRUPOS.forEach((grupo, grupoIndex) => {
const cor = PID_DC_CATEGORIA_CORES[grupoIndex % PID_DC_CATEGORIA_CORES.length];
const totalGrupo = grupo.regras.reduce((soma, chave) => soma + (porRegra.get(chave) || []).length, 0);
if (grupo.somenteComAchados && totalGrupo === 0) return;
const card = document.createElement("div");
card.className = "dc-categoria-card";
@ -1880,8 +1893,32 @@ document.addEventListener("DOMContentLoaded", async () => {
return `${conta.codigo}|${conta.descricao}`;
}
// Mesma chave de ContabilObservacao.chave_linha() no backend (ver
// portal_api/dashboard_contabil/chaves.py): o caminho na árvore, não a
// descrição isolada. O mesmo rótulo aparece em ramos diferentes no mesmo
// nível ("DESPESAS COM PESSOAL" sob "DESPESAS DE VENDAS" e sob "DESPESAS
// ADMINISTRATIVAS") e o par `(descricao, nivel)` fazia a observação de uma
// vazar pra outra, mesmo problema que a descrição já tinha resolvido no
// Balancete. Como a chave depende dos grupos acima da linha, ela é
// calculada pela tabela inteira em dcAplicaChavesObs() (no começo de
// renderDre()/renderAnaliseVertical()) e guardada na própria linha — uma
// linha isolada não sabe em que ramo está.
function dcChaveObsLinha(linha) {
return `${linha.descricao}|${linha.nivel}`;
return linha.chave_obs || `${linha.descricao}|${Math.max(0, linha.nivel)}`;
}
// Percorre a árvore na ordem de leitura mantendo a pilha de ancestrais —
// mesmo algoritmo de chaves.caminhos_linhas() no backend, inclusive o
// descarte de nível pulado (buraco na pilha) e o max(0, nivel).
function dcAplicaChavesObs(linhas) {
const pilha = [];
(linhas || []).forEach((linha) => {
const nivel = Math.max(0, linha.nivel);
pilha.length = nivel;
pilha.push(linha.descricao);
const caminho = pilha.filter((parte) => parte !== undefined && parte !== "").join(" > ");
linha.chave_obs = `${caminho}|${nivel}`;
});
}
function dcReindexaObservacoes() {
@ -2415,6 +2452,10 @@ document.addEventListener("DOMContentLoaded", async () => {
body.innerHTML = "";
const concluida = apuracaoAtual.status === "concluida";
const linhas = apuracaoAtual.linhas_dre;
// A cada render, e não só ao abrir a apuração: marcar uma linha como
// validada troca o objeto pelo retorno da API (dcClicarValidadoLinha),
// que vem sem a chave.
dcAplicaChavesObs(linhas);
const niveis = linhas.map((linha) => Math.max(0, linha.nivel));
const temFilhos = linhas.map((_, i) => i + 1 < linhas.length && niveis[i + 1] > niveis[i]);
@ -2556,6 +2597,8 @@ document.addEventListener("DOMContentLoaded", async () => {
body.innerHTML = "";
const concluida = apuracaoAtual.status === "concluida";
const linhas = apuracaoAtual.linhas_analise_vertical;
// Ver o comentário em renderDre().
dcAplicaChavesObs(linhas);
const colspan = 2 + (apuracaoAtual.analise_vertical_meses || []).length * 2;
const nivelFn = (l) => Math.max(0, l.nivel);
const niveis = linhas.map(nivelFn);