Validações no reprocessamento
This commit is contained in:
parent
302bbea163
commit
e4d95266ef
@ -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(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(grep -o '`' \"C:/Users/Depaula/Documents/Portal/static/js/login-intro-aniversario.js\")",
|
||||||
"Bash(\"./.venv/Scripts/python.exe\" -)",
|
"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": [
|
"additionalDirectories": [
|
||||||
"C:\\Users\\Depaula\\AppData\\Local\\Temp\\claude\\c--Users-Depaula-Documents-Portal\\3ea0ee22-e5fd-4030-98b1-ee2e71c16ce0\\scratchpad\\halloween-design",
|
"C:\\Users\\Depaula\\AppData\\Local\\Temp\\claude\\c--Users-Depaula-Documents-Portal\\3ea0ee22-e5fd-4030-98b1-ee2e71c16ce0\\scratchpad\\halloween-design",
|
||||||
|
|||||||
@ -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`. |
|
| `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`. |
|
| `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`. |
|
| `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)
|
### API (sessão + CSRF, não token)
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
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.
|
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.
|
||||||
|
|||||||
@ -52,6 +52,8 @@ Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, li
|
|||||||
|
|
||||||
### As 9 regras
|
### 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.
|
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).
|
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`.
|
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").
|
8. **`descricao_generica`** (baixa) — descrição exatamente `"DIVERSOS"` com saldo relevante (o ITD cita esse caso: "o contador deverá realocar estes lançamentos a conta pertinente").
|
||||||
9. **`variacao_atipica_dre`** (baixa) — usa a seção "Demonstração Mensal (Análise Vertical)" do próprio PDF (`atual.linhas_analise_vertical`), não o histórico do Portal. Compara os **2 meses mais recentes** dessa tabela pelo `percentual` de cada linha sobre a Receita Operacional Bruta. Dispara quando o salto entre os 2 meses é de pelo menos `VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO` (**1 ponto percentual** — piso para não disparar em saltos percentualmente grandes só porque a base já era perto de zero) **e**, quando o percentual anterior não é zero, `VARIACAO_LIMIAR_PERCENTUAL` (**65%** de variação relativa). Roda mesmo na 1ª apuração de uma empresa, desde que o PDF traga a seção.
|
9. **`variacao_atipica_dre`** (baixa) — usa a seção "Demonstração Mensal (Análise Vertical)" do próprio PDF (`atual.linhas_analise_vertical`), não o histórico do Portal. Compara os **2 meses mais recentes** dessa tabela pelo `percentual` de cada linha sobre a Receita Operacional Bruta. Dispara quando o salto entre os 2 meses é de pelo menos `VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO` (**1 ponto percentual** — piso para não disparar em saltos percentualmente grandes só porque a base já era perto de zero) **e**, quando o percentual anterior não é zero, `VARIACAO_LIMIAR_PERCENTUAL` (**65%** de variação relativa). Roda mesmo na 1ª apuração de uma empresa, desde que o PDF traga a seção.
|
||||||
|
|
||||||
|
### 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.
|
**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`
|
### 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`.
|
- **`ContabilLinhaDre`** — uma linha da DRE, sem código de classificação. Mesmos `validado`/`alterada_reprocessamento`/`valor_anterior_reprocessamento`.
|
||||||
- **`ContabilLinhaAnaliseVertical`** — mesma árvore/descrição/nível da DRE, mas `valores` (`JSONField`) guarda um `{"valor": "...", "percentual": "..."}` **por mês**, gravado como **texto, não float**, para não perder precisão; alinhado por posição com `ContabilApuracao.analise_vertical_meses`. `valores_anterior_reprocessamento` tem o mesmo formato (não existe um valor único aqui). Lista vazia quando o PDF não traz a seção — relatório antigo ou empresa sem essa seção habilitada no Questor; a aba correspondente some nesse caso.
|
- **`ContabilLinhaAnaliseVertical`** — mesma árvore/descrição/nível da DRE, mas `valores` (`JSONField`) guarda um `{"valor": "...", "percentual": "..."}` **por mês**, gravado como **texto, não float**, para não perder precisão; alinhado por posição com `ContabilApuracao.analise_vertical_meses`. `valores_anterior_reprocessamento` tem o mesmo formato (não existe um valor único aqui). Lista vazia quando o PDF não traz a seção — relatório antigo ou empresa sem essa seção habilitada no Questor; a aba correspondente some nesse caso.
|
||||||
|
|
||||||
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
|
### 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**:
|
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.
|
- **`_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.
|
**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.
|
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.
|
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.
|
> **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.
|
- **Códigos de classificação fixos** — se um cliente usar plano de contas com numeração diferente, indicadores e a regra de caixa saem errados **silenciosamente**. Revisar contra mais balancetes reais de empresas diferentes antes de confiar cegamente num valor exibido ao cliente.
|
||||||
- **Descrições coladas em PDF de fonte atípica** — não têm correção segura; a ferramenta avisa por badge. Valores monetários nunca são afetados.
|
- **Descrições coladas em PDF de fonte atípica** — não têm correção segura; a ferramenta avisa por badge. Valores monetários nunca são afetados.
|
||||||
- **Duas cópias mantidas à mão**: os SVGs de ícone (Python + JS) 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).
|
- **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.
|
- **`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.
|
- **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.
|
||||||
|
|||||||
@ -8,7 +8,7 @@ Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil
|
|||||||
|
|
||||||
## Onde mexer
|
## Onde mexer
|
||||||
|
|
||||||
- `portal_api/dashboard_contabil/` — `parser.py` (extração do PDF via `pdfplumber`), `regras.py` (motor de regras de auditoria), `indicadores.py` (indicadores financeiros do relatório), `pipeline.py` (orquestração), `modelos.py` (dataclasses), `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`).
|
- `ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical`/`ContabilAchado`/`ContabilObservacao`/`ContabilObservacaoEdicao` (`portal_api/models.py`).
|
||||||
- `dashboard-contabil.html` / `static/js/dashboard-contabil.js` / `static/css/dashboard-contabil.css`.
|
- `dashboard-contabil.html` / `static/js/dashboard-contabil.js` / `static/css/dashboard-contabil.css`.
|
||||||
- `dashboard-contabil-relatorio.html` (relatório em si) / `portal_api/templatetags/contabil_extras.py` (filtros de formatação).
|
- `dashboard-contabil-relatorio.html` (relatório em si) / `portal_api/templatetags/contabil_extras.py` (filtros de formatação).
|
||||||
|
|||||||
91
portal_api/dashboard_contabil/chaves.py
Normal file
91
portal_api/dashboard_contabil/chaves.py
Normal 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)]
|
||||||
@ -321,6 +321,82 @@ def regra_variacao_atipica_dre(atual: ResultadoExtracao, historico: list[Snapsho
|
|||||||
return achados
|
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 = [
|
REGRAS = [
|
||||||
regra_balanceamento_ativo_passivo,
|
regra_balanceamento_ativo_passivo,
|
||||||
regra_debito_credito_divergente,
|
regra_debito_credito_divergente,
|
||||||
|
|||||||
@ -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),
|
||||||
|
]
|
||||||
25
portal_api/migrations/0079_contabil_ordering_determinista.py
Normal file
25
portal_api/migrations/0079_contabil_ordering_determinista.py
Normal 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)'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -12,6 +12,7 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from .custo_contratacao import tabelas as tabelas_custo_contratacao
|
from .custo_contratacao import tabelas as tabelas_custo_contratacao
|
||||||
from .custo_contratacao.calculo import ParametrosFiscais
|
from .custo_contratacao.calculo import ParametrosFiscais
|
||||||
|
from .dashboard_contabil import chaves as chaves_contabil
|
||||||
|
|
||||||
LINK_FERRAMENTA_ICONE_MAX_BYTES = 2 * 1024 * 1024
|
LINK_FERRAMENTA_ICONE_MAX_BYTES = 2 * 1024 * 1024
|
||||||
ACESSO_GERAL_OBSERVACOES_MAX_CHARS = 2_000_000
|
ACESSO_GERAL_OBSERVACOES_MAX_CHARS = 2_000_000
|
||||||
@ -2148,7 +2149,12 @@ class ContabilConta(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Conta do Balancete (Relatório Contábil)"
|
verbose_name = "Conta do Balancete (Relatório Contábil)"
|
||||||
verbose_name_plural = "Contas 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:
|
def __str__(self) -> str:
|
||||||
return f"{self.codigo} {self.descricao}"
|
return f"{self.codigo} {self.descricao}"
|
||||||
@ -2179,7 +2185,8 @@ class ContabilLinhaDre(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Linha da DRE (Relatório Contábil)"
|
verbose_name = "Linha da DRE (Relatório Contábil)"
|
||||||
verbose_name_plural = "Linhas 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:
|
def __str__(self) -> str:
|
||||||
return f"{self.descricao}"
|
return f"{self.descricao}"
|
||||||
@ -2214,7 +2221,8 @@ class ContabilLinhaAnaliseVertical(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Linha da Análise Vertical (Relatório Contábil)"
|
verbose_name = "Linha da Análise Vertical (Relatório Contábil)"
|
||||||
verbose_name_plural = "Linhas 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:
|
def __str__(self) -> str:
|
||||||
return f"{self.descricao}"
|
return f"{self.descricao}"
|
||||||
@ -2310,7 +2318,8 @@ class ContabilObservacao(models.Model):
|
|||||||
apuração específica (uma linha é recriada/ressincronizada a cada
|
apuração específica (uma linha é recriada/ressincronizada a cada
|
||||||
apuração/reprocessamento): `"codigo|descricao"` no Balancete (ver
|
apuração/reprocessamento): `"codigo|descricao"` no Balancete (ver
|
||||||
`chave_conta()` — `codigo` de classificação sozinho não é único, 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
|
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
|
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 é
|
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)
|
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_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 —
|
# 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
|
# 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).
|
# 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.
|
"vazar" pra todas as outras que compartilham a classificação.
|
||||||
`descricao` desambigua, mesmo espírito de `chave_linha()` pra
|
`descricao` desambigua, mesmo espírito de `chave_linha()` pra
|
||||||
DRE/Análise Vertical."""
|
DRE/Análise Vertical."""
|
||||||
return f"{codigo}|{descricao}"
|
return chaves_contabil.chave_conta(codigo, descricao)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def chave_linha(descricao: str, nivel: int) -> str:
|
def chave_linha(caminho: str, nivel: int) -> str:
|
||||||
"""Chave natural de uma linha da DRE/Análise Vertical — o par
|
"""Chave natural de uma linha da DRE/Análise Vertical, a partir do
|
||||||
`(descricao, nivel)` de `_contabil_sincroniza_linhas_dre()`, que
|
**caminho na árvore** (`chaves_contabil.caminhos_linhas()`), não da
|
||||||
desambigua descrições repetidas em ramos diferentes da árvore."""
|
descrição isolada: o mesmo rótulo aparece em ramos diferentes no
|
||||||
return f"{descricao}|{nivel}"
|
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
|
@classmethod
|
||||||
def vigentes_para(cls, codigo_empresa: str, competencia: date) -> models.QuerySet["ContabilObservacao"]:
|
def vigentes_para(cls, codigo_empresa: str, competencia: date) -> models.QuerySet["ContabilObservacao"]:
|
||||||
|
|||||||
@ -38,11 +38,13 @@ from rest_framework.serializers import ModelSerializer
|
|||||||
from . import catalogo
|
from . import catalogo
|
||||||
from .custo_contratacao.calculo import calcula_custo_empregado
|
from .custo_contratacao.calculo import calcula_custo_empregado
|
||||||
from .custo_contratacao.pdf import gera_pdf_simulacao
|
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 exportacao as dashboard_contabil_exportacao
|
||||||
from .dashboard_contabil import formula as dashboard_contabil_formula
|
from .dashboard_contabil import formula as dashboard_contabil_formula
|
||||||
from .dashboard_contabil import indicadores as dashboard_contabil_indicadores
|
from .dashboard_contabil import indicadores as dashboard_contabil_indicadores
|
||||||
from .dashboard_contabil import modelos as dashboard_contabil_modelos
|
from .dashboard_contabil import modelos as dashboard_contabil_modelos
|
||||||
from .dashboard_contabil import pipeline as dashboard_contabil_pipeline
|
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 .dashboard_contabil import resumo_pdf as dashboard_contabil_resumo_pdf
|
||||||
from .templatetags import contabil_extras as dashboard_contabil_extras
|
from .templatetags import contabil_extras as dashboard_contabil_extras
|
||||||
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
|
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
|
||||||
@ -3440,8 +3442,8 @@ class _ContabilDadosIndicadores:
|
|||||||
|
|
||||||
|
|
||||||
def _contabil_coleta_dados_indicadores(apuracao: ContabilApuracao) -> _ContabilDadosIndicadores:
|
def _contabil_coleta_dados_indicadores(apuracao: ContabilApuracao) -> _ContabilDadosIndicadores:
|
||||||
contas = list(apuracao.contas.order_by("ordem"))
|
contas = list(apuracao.contas.order_by("ordem", "id"))
|
||||||
linhas_dre = list(apuracao.linhas_dre.order_by("ordem"))
|
linhas_dre = list(apuracao.linhas_dre.order_by("ordem", "id"))
|
||||||
contas_atuais = {conta.codigo: conta.saldo_atual for conta in contas}
|
contas_atuais = {conta.codigo: conta.saldo_atual for conta in contas}
|
||||||
dre_atual = {linha.descricao: linha.valor for linha in linhas_dre}
|
dre_atual = {linha.descricao: linha.valor for linha in linhas_dre}
|
||||||
resultado_liquido = linhas_dre[-1].valor if linhas_dre else Decimal(0)
|
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],
|
nivel_fn: Callable[[Any], int],
|
||||||
px_por_nivel: int,
|
px_por_nivel: int,
|
||||||
observacoes_por_chave: dict[str, list[ContabilObservacao]] | None = None,
|
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]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Monta o contexto de uma árvore recolhível (Balancete/DRE) pro
|
"""Monta o contexto de uma árvore recolhível (Balancete/DRE) pro
|
||||||
relatório "Gerar Dashboard" — mesmo algoritmo de `dcContaNivel()`/
|
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`
|
linhas já prontas (ver `dashboard-contabil-relatorio.html`). `colapsado_padrao`
|
||||||
marca os itens que devem nascer recolhidos (ver `_CONTABIL_NIVEL_ABERTO_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
|
penduram em cada item as observações vigentes daquela conta/linha que o
|
||||||
contador marcou pra mostrar ao cliente — casadas pela chave natural do
|
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]
|
niveis = [nivel_fn(item) for item in itens]
|
||||||
contexto = []
|
contexto = []
|
||||||
for i, item in enumerate(itens):
|
for i, item in enumerate(itens):
|
||||||
tem_filhos = i + 1 < len(itens) and niveis[i + 1] > niveis[i]
|
tem_filhos = i + 1 < len(itens) and niveis[i + 1] > niveis[i]
|
||||||
observacoes: list[ContabilObservacao] = []
|
observacoes: list[ContabilObservacao] = []
|
||||||
if observacoes_por_chave is not None and chave_fn is not None:
|
if observacoes_por_chave is not None and chaves is not None:
|
||||||
observacoes = observacoes_por_chave.get(chave_fn(item), [])
|
observacoes = observacoes_por_chave.get(chaves[i], [])
|
||||||
contexto.append(
|
contexto.append(
|
||||||
{
|
{
|
||||||
chave: item,
|
chave: item,
|
||||||
@ -3807,14 +3812,32 @@ def _contabil_arvore_contexto(
|
|||||||
return 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
|
"""Chave natural de uma conta/linha pro histórico de observações — as
|
||||||
mesmas usadas por `_contabil_sincroniza_*()` no reprocessamento
|
mesmas usadas por `_contabil_sincroniza_*()` no reprocessamento
|
||||||
(`(codigo, descricao)` no Balancete, `(descricao, nivel)` na DRE/Análise
|
(`(codigo, descricao)` no Balancete, caminho na árvore + nível na
|
||||||
Vertical), pra uma observação seguir a mesma conta de uma competência pra
|
DRE/Análise Vertical), pra uma observação seguir a mesma conta de uma
|
||||||
outra."""
|
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:
|
if alvo_tipo == ContabilObservacao.ALVO_CONTA:
|
||||||
return ContabilObservacao.chave_conta(alvo.codigo, alvo.descricao)
|
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)
|
return ContabilObservacao.chave_linha(alvo.descricao, alvo.nivel)
|
||||||
|
|
||||||
|
|
||||||
@ -3834,9 +3857,63 @@ def _contabil_observacoes_por_chave(apuracao: ContabilApuracao, alvo_tipo: str)
|
|||||||
return agrupadas
|
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(
|
def _contabil_sincroniza_contas(
|
||||||
apuracao: ContabilApuracao, contas_extraidas: list[dashboard_contabil_modelos.LinhaBalanceteExtraida]
|
apuracao: ContabilApuracao, contas_extraidas: list[dashboard_contabil_modelos.LinhaBalanceteExtraida]
|
||||||
) -> None:
|
) -> list[tuple[str, str]]:
|
||||||
"""Resincroniza `ContabilConta` a partir de um reprocessamento
|
"""Resincroniza `ContabilConta` a partir de um reprocessamento
|
||||||
(`ContabilApuracaoViewSet.reprocessar()`) — casa pelo par
|
(`ContabilApuracaoViewSet.reprocessar()`) — casa pelo par
|
||||||
`(codigo, descricao)` (chave natural já usada pelo histórico de
|
`(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
|
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
|
descrição) é tratada como uma conta diferente — a antiga é excluída e uma
|
||||||
nova é criada, mesmo trade-off que `_contabil_sincroniza_linhas_dre()`
|
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
|
ela não mora mais na linha, e sim em `ContabilObservacao` (histórico por
|
||||||
empresa+conta)."""
|
empresa+conta). Devolve `(origem, rótulo)` de cada conta excluída, pro
|
||||||
antigas = {(conta.codigo, conta.descricao): conta for conta in apuracao.contas.all()}
|
reprocessamento virar apontamento na aba Observações."""
|
||||||
vistos: set[tuple[str, str]] = set()
|
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):
|
for indice, extraida in enumerate(contas_extraidas):
|
||||||
tipo = extraida.tipo or "A"
|
tipo = extraida.tipo or "A"
|
||||||
chave = (extraida.codigo, extraida.descricao)
|
chave = ContabilObservacao.chave_conta(extraida.codigo, extraida.descricao)
|
||||||
antiga = antigas.get(chave)
|
antiga = _contabil_proxima_da_fila(antigas, chave)
|
||||||
if antiga is not None:
|
if antiga is not None:
|
||||||
vistos.add(chave)
|
|
||||||
alterou = (
|
alterou = (
|
||||||
antiga.tipo != tipo
|
antiga.tipo != tipo
|
||||||
or antiga.saldo_anterior != extraida.saldo_anterior
|
or antiga.saldo_anterior != extraida.saldo_anterior
|
||||||
@ -3895,27 +3974,28 @@ def _contabil_sincroniza_contas(
|
|||||||
saldo_atual=extraida.saldo_atual,
|
saldo_atual=extraida.saldo_atual,
|
||||||
)
|
)
|
||||||
|
|
||||||
for chave, antiga in antigas.items():
|
return [
|
||||||
if chave not in vistos:
|
(dashboard_contabil_regras.ORIGEM_BALANCETE, f"{conta.codigo} {conta.descricao}")
|
||||||
antiga.delete()
|
for _, conta in _contabil_exclui_sobras(antigas)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _contabil_sincroniza_linhas_dre(
|
def _contabil_sincroniza_linhas_dre(
|
||||||
apuracao: ContabilApuracao, linhas_extraidas: list[dashboard_contabil_modelos.LinhaDreExtraida]
|
apuracao: ContabilApuracao, linhas_extraidas: list[dashboard_contabil_modelos.LinhaDreExtraida]
|
||||||
) -> None:
|
) -> list[tuple[str, str]]:
|
||||||
"""Mesmo espírito de `_contabil_sincroniza_contas()`, sobre
|
"""Mesmo espírito de `_contabil_sincroniza_contas()`, sobre
|
||||||
`ContabilLinhaDre` — sem código de classificação, a chave natural é
|
`ContabilLinhaDre` — sem código de classificação, a chave natural é o
|
||||||
`(descricao, nivel)` (o par que já desambigua a maioria das descrições
|
**caminho na árvore** mais o nível (ver `dashboard_contabil.chaves`), que
|
||||||
repetidas em ramos diferentes da árvore, ex. "COMISSÕES SOBRE VENDAS"
|
é o que distingue o mesmo rótulo repetido em ramos diferentes no mesmo
|
||||||
aparecendo em mais de um nível)."""
|
nível ("DESPESAS COM PESSOAL" sob Vendas e sob Administrativas). Devolve
|
||||||
antigas = {(linha.descricao, linha.nivel): linha for linha in apuracao.linhas_dre.all()}
|
`(origem, rótulo)` de cada linha excluída, como `_contabil_sincroniza_contas()`."""
|
||||||
vistos: set[tuple[str, int]] = set()
|
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:
|
for chave, linha in zip(chaves_extraidas, linhas_extraidas):
|
||||||
chave = (linha.descricao, linha.nivel)
|
antiga = _contabil_proxima_da_fila(antigas, chave)
|
||||||
antiga = antigas.get(chave)
|
|
||||||
if antiga is not None:
|
if antiga is not None:
|
||||||
vistos.add(chave)
|
|
||||||
alterou = antiga.totalizador != linha.totalizador or antiga.valor != linha.valor
|
alterou = antiga.totalizador != linha.totalizador or antiga.valor != linha.valor
|
||||||
antiga.valor_anterior_reprocessamento = antiga.valor if alterou else None
|
antiga.valor_anterior_reprocessamento = antiga.valor if alterou else None
|
||||||
antiga.ordem = linha.ordem
|
antiga.ordem = linha.ordem
|
||||||
@ -3934,9 +4014,10 @@ def _contabil_sincroniza_linhas_dre(
|
|||||||
totalizador=linha.totalizador,
|
totalizador=linha.totalizador,
|
||||||
)
|
)
|
||||||
|
|
||||||
for chave, antiga in antigas.items():
|
return [
|
||||||
if chave not in vistos:
|
(dashboard_contabil_regras.ORIGEM_DRE, _contabil_rotulo_linha_removida(chave))
|
||||||
antiga.delete()
|
for chave, _ in _contabil_exclui_sobras(antigas)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _contabil_valores_analise_vertical_iguais(
|
def _contabil_valores_analise_vertical_iguais(
|
||||||
@ -3952,20 +4033,20 @@ def _contabil_valores_analise_vertical_iguais(
|
|||||||
|
|
||||||
def _contabil_sincroniza_linhas_analise_vertical(
|
def _contabil_sincroniza_linhas_analise_vertical(
|
||||||
apuracao: ContabilApuracao, linhas_extraidas: list[dashboard_contabil_modelos.LinhaAnaliseVerticalExtraida]
|
apuracao: ContabilApuracao, linhas_extraidas: list[dashboard_contabil_modelos.LinhaAnaliseVerticalExtraida]
|
||||||
) -> None:
|
) -> list[tuple[str, str]]:
|
||||||
"""Mesmo espírito de `_contabil_sincroniza_linhas_dre()`, sobre
|
"""Mesmo espírito de `_contabil_sincroniza_linhas_dre()`, sobre
|
||||||
`ContabilLinhaAnaliseVertical` — além de `totalizador`, compara a lista
|
`ContabilLinhaAnaliseVertical` — além de `totalizador`, compara a lista
|
||||||
inteira de `valores` (convertendo de volta pra `Decimal`, já que é
|
inteira de `valores` (convertendo de volta pra `Decimal`, já que é
|
||||||
gravada como texto no JSONField)."""
|
gravada como texto no JSONField). Mesmo retorno de `(origem, rótulo)` das
|
||||||
antigas = {(linha.descricao, linha.nivel): linha for linha in apuracao.linhas_analise_vertical.all()}
|
linhas excluídas."""
|
||||||
vistos: set[tuple[str, int]] = set()
|
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:
|
for chave, linha in zip(chaves_extraidas, linhas_extraidas):
|
||||||
chave = (linha.descricao, linha.nivel)
|
antiga = _contabil_proxima_da_fila(antigas, chave)
|
||||||
antiga = antigas.get(chave)
|
|
||||||
novos_valores = [{"valor": str(v.valor), "percentual": str(v.percentual)} for v in linha.valores]
|
novos_valores = [{"valor": str(v.valor), "percentual": str(v.percentual)} for v in linha.valores]
|
||||||
if antiga is not None:
|
if antiga is not None:
|
||||||
vistos.add(chave)
|
|
||||||
alterou = antiga.totalizador != linha.totalizador or not _contabil_valores_analise_vertical_iguais(
|
alterou = antiga.totalizador != linha.totalizador or not _contabil_valores_analise_vertical_iguais(
|
||||||
antiga.valores, linha.valores
|
antiga.valores, linha.valores
|
||||||
)
|
)
|
||||||
@ -3986,9 +4067,10 @@ def _contabil_sincroniza_linhas_analise_vertical(
|
|||||||
valores=novos_valores,
|
valores=novos_valores,
|
||||||
)
|
)
|
||||||
|
|
||||||
for chave, antiga in antigas.items():
|
return [
|
||||||
if chave not in vistos:
|
(dashboard_contabil_regras.ORIGEM_ANALISE_VERTICAL, _contabil_rotulo_linha_removida(chave))
|
||||||
antiga.delete()
|
for chave, _ in _contabil_exclui_sobras(antigas)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _contabil_recria_achados(
|
def _contabil_recria_achados(
|
||||||
@ -3996,8 +4078,10 @@ def _contabil_recria_achados(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Recria do zero todo `ContabilAchado` de um reprocessamento — apaga
|
"""Recria do zero todo `ContabilAchado` de um reprocessamento — apaga
|
||||||
**todos** os achados existentes da apuração e recria a partir do motor de
|
**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
|
regras rodado sobre o PDF novo (mais os avisos de conta/linha removida,
|
||||||
acima). Decisão revisada explicitamente pelo usuário (substitui a
|
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`/
|
decisão anterior de preservar `status`/`observacao_contador`/`tratado_por`/
|
||||||
`tratado_em`/`oculto_no_relatorio` — ver histórico no CHANGELOG.md do
|
`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
|
pacote): um apontamento automático que não dispara mais com os dados
|
||||||
@ -4296,10 +4380,22 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
|||||||
apuracao.save()
|
apuracao.save()
|
||||||
novo_arquivo_salvo = True
|
novo_arquivo_salvo = True
|
||||||
|
|
||||||
_contabil_sincroniza_contas(apuracao, resultado.extracao.contas)
|
# Conta/linha que sumiu do arquivo novo vira apontamento na
|
||||||
_contabil_sincroniza_linhas_dre(apuracao, resultado.extracao.linhas_dre)
|
# aba Observações (pedido explícito do usuário): a exclusão
|
||||||
_contabil_sincroniza_linhas_analise_vertical(apuracao, resultado.extracao.linhas_analise_vertical)
|
# em si é silenciosa, e uma linha desaparecer entre um
|
||||||
_contabil_recria_achados(apuracao, resultado.achados)
|
# 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)
|
ContabilApuracaoReprocessamento.objects.create(apuracao=apuracao, reprocessado_por=request.user)
|
||||||
except Exception:
|
except Exception:
|
||||||
if novo_arquivo_salvo:
|
if novo_arquivo_salvo:
|
||||||
@ -4364,9 +4460,9 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
|||||||
aba (`window.open`), navegação de verdade, não um blob — precisava
|
aba (`window.open`), navegação de verdade, não um blob — precisava
|
||||||
ser GET pra isso funcionar (ver `dashboard-contabil.js`)."""
|
ser GET pra isso funcionar (ver `dashboard-contabil.js`)."""
|
||||||
apuracao = self.get_object()
|
apuracao = self.get_object()
|
||||||
contas = list(apuracao.contas.order_by("ordem"))
|
contas = list(apuracao.contas.order_by("ordem", "id"))
|
||||||
linhas_dre = list(apuracao.linhas_dre.order_by("ordem"))
|
linhas_dre = list(apuracao.linhas_dre.order_by("ordem", "id"))
|
||||||
linhas_analise_vertical = list(apuracao.linhas_analise_vertical.order_by("ordem"))
|
linhas_analise_vertical = list(apuracao.linhas_analise_vertical.order_by("ordem", "id"))
|
||||||
|
|
||||||
dados_resumo = _contabil_dados_resumo(apuracao)
|
dados_resumo = _contabil_dados_resumo(apuracao)
|
||||||
observacoes_visiveis = dados_resumo["observacoes_visiveis"]
|
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
|
observacao.ancora = f"{prefixo}-{alvo_id}" if alvo_id is not None else None
|
||||||
return observacoes
|
return observacoes
|
||||||
|
|
||||||
mapa_conta_por_chave = {ContabilObservacao.chave_conta(c.codigo, c.descricao): c.id for c in contas}
|
chaves_contas = [ContabilObservacao.chave_conta(c.codigo, c.descricao) for c in contas]
|
||||||
mapa_dre_por_chave = {ContabilObservacao.chave_linha(l.descricao, l.nivel): l.id for l in linhas_dre}
|
chaves_dre = dashboard_contabil_chaves.chaves_linhas(linhas_dre)
|
||||||
mapa_av_por_chave = {
|
chaves_av = dashboard_contabil_chaves.chaves_linhas(linhas_analise_vertical)
|
||||||
ContabilObservacao.chave_linha(l.descricao, l.nivel): l.id for l in 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 = {
|
contexto = {
|
||||||
"apuracao": apuracao,
|
"apuracao": apuracao,
|
||||||
@ -4410,7 +4507,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
|||||||
lambda c: c.codigo.count("."),
|
lambda c: c.codigo.count("."),
|
||||||
18,
|
18,
|
||||||
observacoes_por_tipo[ContabilObservacao.ALVO_CONTA],
|
observacoes_por_tipo[ContabilObservacao.ALVO_CONTA],
|
||||||
lambda c: ContabilObservacao.chave_conta(c.codigo, c.descricao),
|
chaves_contas,
|
||||||
),
|
),
|
||||||
"linhas_dre": _contabil_arvore_contexto(
|
"linhas_dre": _contabil_arvore_contexto(
|
||||||
linhas_dre,
|
linhas_dre,
|
||||||
@ -4418,7 +4515,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
|||||||
lambda l: max(0, l.nivel),
|
lambda l: max(0, l.nivel),
|
||||||
16,
|
16,
|
||||||
observacoes_por_tipo[ContabilObservacao.ALVO_DRE],
|
observacoes_por_tipo[ContabilObservacao.ALVO_DRE],
|
||||||
lambda l: ContabilObservacao.chave_linha(l.descricao, l.nivel),
|
chaves_dre,
|
||||||
),
|
),
|
||||||
"analise_vertical_meses": apuracao.analise_vertical_meses,
|
"analise_vertical_meses": apuracao.analise_vertical_meses,
|
||||||
"linhas_analise_vertical": _contabil_arvore_contexto(
|
"linhas_analise_vertical": _contabil_arvore_contexto(
|
||||||
@ -4427,7 +4524,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
|||||||
lambda l: max(0, l.nivel),
|
lambda l: max(0, l.nivel),
|
||||||
16,
|
16,
|
||||||
observacoes_por_tipo[ContabilObservacao.ALVO_ANALISE_VERTICAL],
|
observacoes_por_tipo[ContabilObservacao.ALVO_ANALISE_VERTICAL],
|
||||||
lambda l: ContabilObservacao.chave_linha(l.descricao, l.nivel),
|
chaves_av,
|
||||||
),
|
),
|
||||||
"observacoes_contas": _com_ancora(
|
"observacoes_contas": _com_ancora(
|
||||||
[observacao for observacao in observacoes_visiveis if observacao.alvo_tipo == ContabilObservacao.ALVO_CONTA],
|
[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'."})
|
raise ValidationError({"parte": "Informe 'balancete' ou 'dre'."})
|
||||||
|
|
||||||
if parte == "balancete":
|
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(
|
conteudo = dashboard_contabil_exportacao.gera_xlsx_balancete(
|
||||||
apuracao.nome_empresa,
|
apuracao.nome_empresa,
|
||||||
apuracao.cnpj,
|
apuracao.cnpj,
|
||||||
@ -4633,7 +4730,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
|||||||
)
|
)
|
||||||
nome_arquivo = f"balancete_{apuracao.codigo_empresa}_{apuracao.competencia:%m-%Y}.xlsx"
|
nome_arquivo = f"balancete_{apuracao.codigo_empresa}_{apuracao.competencia:%m-%Y}.xlsx"
|
||||||
else:
|
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(
|
conteudo = dashboard_contabil_exportacao.gera_xlsx_dre(
|
||||||
apuracao.nome_empresa,
|
apuracao.nome_empresa,
|
||||||
apuracao.cnpj,
|
apuracao.cnpj,
|
||||||
@ -4811,7 +4908,7 @@ class ContabilObservacaoViewSet(viewsets.ModelViewSet):
|
|||||||
observacao = ContabilObservacao.objects.create(
|
observacao = ContabilObservacao.objects.create(
|
||||||
codigo_empresa=apuracao.codigo_empresa,
|
codigo_empresa=apuracao.codigo_empresa,
|
||||||
alvo_tipo=dados["alvo_tipo"],
|
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,
|
alvo_rotulo=alvo.descricao,
|
||||||
apuracao_origem=apuracao,
|
apuracao_origem=apuracao,
|
||||||
competencia_origem=apuracao.competencia,
|
competencia_origem=apuracao.competencia,
|
||||||
|
|||||||
@ -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
|
// 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
|
// 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
|
// 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 = [
|
const PID_DC_REGRAS = [
|
||||||
{ chave: "balanceamento_ativo_passivo", label: "Balanceamento Ativo x Passivo" },
|
{ chave: "balanceamento_ativo_passivo", label: "Balanceamento Ativo x Passivo" },
|
||||||
{ chave: "debito_credito_divergente", label: "Débito x Crédito" },
|
{ 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: "conta_deveria_zerar", label: "Contas que Deveriam Zerar" },
|
||||||
{ chave: "descricao_generica", label: "Descrição Genérica" },
|
{ chave: "descricao_generica", label: "Descrição Genérica" },
|
||||||
{ chave: "variacao_atipica_dre", label: "Variação Atípica na DRE" },
|
{ chave: "variacao_atipica_dre", label: "Variação Atípica na DRE" },
|
||||||
|
{ chave: "item_removido_reprocessamento", label: "Contas e Linhas Removidas" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Agrupamento temático das 9 regras acima pro resumo da aba Observações —
|
// 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",
|
label: "Variações e Indicadores",
|
||||||
regras: ["variacao_atipica_dre"],
|
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
|
// 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) => {
|
PID_DC_GRUPOS.forEach((grupo, grupoIndex) => {
|
||||||
const cor = PID_DC_CATEGORIA_CORES[grupoIndex % PID_DC_CATEGORIA_CORES.length];
|
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);
|
const totalGrupo = grupo.regras.reduce((soma, chave) => soma + (porRegra.get(chave) || []).length, 0);
|
||||||
|
if (grupo.somenteComAchados && totalGrupo === 0) return;
|
||||||
|
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "dc-categoria-card";
|
card.className = "dc-categoria-card";
|
||||||
@ -1880,8 +1893,32 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
return `${conta.codigo}|${conta.descricao}`;
|
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) {
|
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() {
|
function dcReindexaObservacoes() {
|
||||||
@ -2415,6 +2452,10 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
const concluida = apuracaoAtual.status === "concluida";
|
const concluida = apuracaoAtual.status === "concluida";
|
||||||
const linhas = apuracaoAtual.linhas_dre;
|
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 niveis = linhas.map((linha) => Math.max(0, linha.nivel));
|
||||||
const temFilhos = linhas.map((_, i) => i + 1 < linhas.length && niveis[i + 1] > niveis[i]);
|
const temFilhos = linhas.map((_, i) => i + 1 < linhas.length && niveis[i + 1] > niveis[i]);
|
||||||
|
|
||||||
@ -2556,6 +2597,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
const concluida = apuracaoAtual.status === "concluida";
|
const concluida = apuracaoAtual.status === "concluida";
|
||||||
const linhas = apuracaoAtual.linhas_analise_vertical;
|
const linhas = apuracaoAtual.linhas_analise_vertical;
|
||||||
|
// Ver o comentário em renderDre().
|
||||||
|
dcAplicaChavesObs(linhas);
|
||||||
const colspan = 2 + (apuracaoAtual.analise_vertical_meses || []).length * 2;
|
const colspan = 2 + (apuracaoAtual.analise_vertical_meses || []).length * 2;
|
||||||
const nivelFn = (l) => Math.max(0, l.nivel);
|
const nivelFn = (l) => Math.max(0, l.nivel);
|
||||||
const niveis = linhas.map(nivelFn);
|
const niveis = linhas.map(nivelFn);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user