From e4d95266efde45d2ca9c51d922195bf2b5302420 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 22 Sep 2026 13:40:59 -0300 Subject: [PATCH] =?UTF-8?q?Valida=C3=A7=C3=B5es=20no=20reprocessamento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 13 +- CLAUDE.md | 2 +- portal_api/dashboard_contabil/CHANGELOG.md | 27 ++ portal_api/dashboard_contabil/CLAUDE.md | 26 +- portal_api/dashboard_contabil/README.md | 2 +- portal_api/dashboard_contabil/chaves.py | 91 +++++++ portal_api/dashboard_contabil/regras.py | 76 ++++++ ...ontabil_observacao_chave_caminho_arvore.py | 123 ++++++++++ .../0079_contabil_ordering_determinista.py | 25 ++ portal_api/models.py | 40 ++- portal_api/views.py | 231 +++++++++++++----- static/js/dashboard-contabil.js | 47 +++- 12 files changed, 616 insertions(+), 87 deletions(-) create mode 100644 portal_api/dashboard_contabil/chaves.py create mode 100644 portal_api/migrations/0078_contabil_observacao_chave_caminho_arvore.py create mode 100644 portal_api/migrations/0079_contabil_ordering_determinista.py diff --git a/.claude/settings.json b/.claude/settings.json index 9bad9a4..f26af79 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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", diff --git a/CLAUDE.md b/CLAUDE.md index 2929c83..73bd38d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/portal_api/dashboard_contabil/CHANGELOG.md b/portal_api/dashboard_contabil/CHANGELOG.md index 12403f9..ebd5fa2 100644 --- a/portal_api/dashboard_contabil/CHANGELOG.md +++ b/portal_api/dashboard_contabil/CHANGELOG.md @@ -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 (``), 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. diff --git a/portal_api/dashboard_contabil/CLAUDE.md b/portal_api/dashboard_contabil/CLAUDE.md index 9cc4105..c92f199 100644 --- a/portal_api/dashboard_contabil/CLAUDE.md +++ b/portal_api/dashboard_contabil/CLAUDE.md @@ -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. diff --git a/portal_api/dashboard_contabil/README.md b/portal_api/dashboard_contabil/README.md index 2b33350..68397bb 100644 --- a/portal_api/dashboard_contabil/README.md +++ b/portal_api/dashboard_contabil/README.md @@ -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). diff --git a/portal_api/dashboard_contabil/chaves.py b/portal_api/dashboard_contabil/chaves.py new file mode 100644 index 0000000..89139df --- /dev/null +++ b/portal_api/dashboard_contabil/chaves.py @@ -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)] diff --git a/portal_api/dashboard_contabil/regras.py b/portal_api/dashboard_contabil/regras.py index 5f886fd..1fd88c5 100644 --- a/portal_api/dashboard_contabil/regras.py +++ b/portal_api/dashboard_contabil/regras.py @@ -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, diff --git a/portal_api/migrations/0078_contabil_observacao_chave_caminho_arvore.py b/portal_api/migrations/0078_contabil_observacao_chave_caminho_arvore.py new file mode 100644 index 0000000..2adbc78 --- /dev/null +++ b/portal_api/migrations/0078_contabil_observacao_chave_caminho_arvore.py @@ -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), + ] diff --git a/portal_api/migrations/0079_contabil_ordering_determinista.py b/portal_api/migrations/0079_contabil_ordering_determinista.py new file mode 100644 index 0000000..03023a8 --- /dev/null +++ b/portal_api/migrations/0079_contabil_ordering_determinista.py @@ -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)'}, + ), + ] diff --git a/portal_api/models.py b/portal_api/models.py index 1f27e55..183600b 100644 --- a/portal_api/models.py +++ b/portal_api/models.py @@ -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"]: diff --git a/portal_api/views.py b/portal_api/views.py index 0b6e264..b1c9f8a 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -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, diff --git a/static/js/dashboard-contabil.js b/static/js/dashboard-contabil.js index 7096895..27b7cde 100644 --- a/static/js/dashboard-contabil.js +++ b/static/js/dashboard-contabil.js @@ -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);