From 2a12eabf62a71dd8d05eddaba4b4ff90c89a180b Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 18 Sep 2026 14:09:43 -0300 Subject: [PATCH] =?UTF-8?q?Corre=C3=A7=C3=A3o=20das=20contas=20com=20sinal?= =?UTF-8?q?=20invertidos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 4 ++- portal_api/dashboard_contabil/CHANGELOG.md | 8 +++++ portal_api/dashboard_contabil/CLAUDE.md | 8 ++++- portal_api/dashboard_contabil/regras.py | 37 ++++++++++++++++++++-- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 25205e4..ed9fc20 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -88,7 +88,9 @@ "Bash(PYTHONPATH=\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\" .venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/cf4a6caf-9798-42a6-bbc8-a8ffbb3debf6/scratchpad/sim_destaque.py\")", "Bash(PYTHONPATH=\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\" .venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/cf4a6caf-9798-42a6-bbc8-a8ffbb3debf6/scratchpad/sim_expandir_tudo.py\")", "Bash(PYTHONPATH=\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\" .venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/cf4a6caf-9798-42a6-bbc8-a8ffbb3debf6/scratchpad/test_reprocessamento_log.py\")", - "Bash(PYTHONPATH=\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\" .venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/cf4a6caf-9798-42a6-bbc8-a8ffbb3debf6/scratchpad/test_recria_achados.py\")" + "Bash(PYTHONPATH=\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\" .venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/cf4a6caf-9798-42a6-bbc8-a8ffbb3debf6/scratchpad/test_recria_achados.py\")", + "Bash(PYTHONPATH=\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\" .venv/Scripts/python.exe \"C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/cf4a6caf-9798-42a6-bbc8-a8ffbb3debf6/scratchpad/test_regra_sinal_invertido.py\")", + "Bash(PYTHONPATH='C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal' .venv/Scripts/python.exe -c ' *)" ] } } diff --git a/portal_api/dashboard_contabil/CHANGELOG.md b/portal_api/dashboard_contabil/CHANGELOG.md index bb9fe27..fab5a8d 100644 --- a/portal_api/dashboard_contabil/CHANGELOG.md +++ b/portal_api/dashboard_contabil/CHANGELOG.md @@ -357,3 +357,11 @@ Pedido explícito do usuário: "sempre que o usuário reprocessar um documento, `_contabil_sincroniza_achados()` (que casava por `(regra, código da conta)` e preservava `status`/`observacao_contador`/`tratado_por`/`tratado_em`/`oculto_no_relatorio`) virou `_contabil_recria_achados()` — apaga **todos** os achados da apuração (`apuracao.achados.all().delete()`) e recria do zero a partir do motor de regras rodado sobre o PDF novo (mesmo `bulk_create()` de `create()`). Todo achado nasce `pendente`, mesmo que a mesma combinação regra+conta já tivesse sido tratada com justificativa antes do reprocessamento — os valores mudaram, então a tratativa antiga deixa de fazer sentido. A preocupação original da rodada 124 (delete+recria quebraria a FK `conta` de um achado preservado) não se aplica mais: não existe mais achado "preservado" através do delete, a FK é sempre resolvida fresca contra as contas já sincronizadas na mesma transação. **`ContabilObservacao` (comentário/observação do contador numa conta, sistema separado desde a rodada 126) não é tocada** — nunca teve relação com achados, é casada por chave natural (empresa+conta) e sobrevive a qualquer reprocessamento, exatamente como pedido. Validado com um script Python isolado (`transaction.atomic()` com rollback forçado, contra uma apuração real já em produção): um achado marcado manualmente como "tratado" com justificativa desapareceu depois de simular `_contabil_recria_achados()` com a mesma lista de achados detectados, dando lugar a um achado novo `pendente` com o mesmo conteúdo (regra/conta/mensagem) mas `id` diferente; uma `ContabilObservacao` criada na mesma apuração permaneceu intacta. Nada persistido em produção. Detalhe técnico completo no `CLAUDE.md` desta pasta. + +### 144. Bug real: "Conta do Passivo/Ativo com saldo invertido" disparava pra conta descendente de uma redutora, não só a filha direta + +Usuário reportou, com print de achados reais ("Conta do Passivo 'DANITHI LTDA'"/"'MPASARABIAHOLDINGPARTICIPAÇÕES E'" com saldo devedor): quando a conta "mãe" tem o sinal `"(-)"` na frente (ex. `"(-) CAPITALA INTEGRALIZAR"`), o saldo "invertido" dos filhos dela é o comportamento esperado da própria natureza daquela conta, não uma inconsistência — mas a regra `saldo_sinal_invertido` só excluía a conta que **em si** começava com `"(-)"`, não suas descendentes. + +Corrigido com `_indices_descendentes_de_conta_redutora()` (novo, `regras.py`) — identifica toda conta com algum ancestral (não só o pai direto) redutora, usando o mesmo algoritmo de pilha de níveis já usado em toda a aplicação pra árvore de contas (nível = `codigo.count(".")`, ordem de leitura do PDF), não comparação de prefixo de código (o Questor reaproveita código de classificação entre contas analíticas irmãs, então string matching não seria confiável pra achar o pai). Validado com uma árvore sintética reproduzindo o exemplo do usuário (confirmando que o achado deixa de disparar com a correção e **dispararia** sem ela) e rodando contra as 3 apurações reais já em produção — a apuração `1751`/TAROBA (a mesma do print) teve exatamente os 2 achados dos sócios suprimidos. + +Não afeta achados já persistidos: os 2 achados reais da apuração `1751` só somem da tela depois de reprocessar essa apuração (o motor de regras roda de novo do zero, ver rodada 143) — nenhuma edição direta no banco foi feita. Detalhe técnico completo no `CLAUDE.md` desta pasta. diff --git a/portal_api/dashboard_contabil/CLAUDE.md b/portal_api/dashboard_contabil/CLAUDE.md index d1b4774..6096a85 100644 --- a/portal_api/dashboard_contabil/CLAUDE.md +++ b/portal_api/dashboard_contabil/CLAUDE.md @@ -32,7 +32,7 @@ Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, li 1. **`balanceamento_ativo_passivo`** (alta) — soma do grupo Ativo (`codigo="1"`) deve fechar **exatamente** com a do Passivo (`codigo="2"`, já vem negativo no relatório) — diferença precisa ser zero, sem tolerância de centavos (removida numa rodada seguinte, pedido explícito do usuário). 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 (mesma remoção de tolerância). **Não é uma checagem trivial de "todo balancete sempre bate"**: como a DRE (Resultado) 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 também transita pelas contas de Patrimônio Líquido do Passivo (ex.: "LUCROS/PREJUÍZOS DO EXERCÍCIO") — confirmado empiricamente contra `792 - balancete 072026.pdf` (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`. -4. **`saldo_sinal_invertido`** (média) — conta analítica (`tipo="A"`) do Ativo (`1.`) com saldo credor, ou do Passivo (`2.`) com saldo devedor, exceto contas redutoras (descrição começando com `"(-)"`, que são esperadas ter o sinal oposto ao grupo). +4. **`saldo_sinal_invertido`** (média) — conta analítica (`tipo="A"`) do Ativo (`1.`) com saldo credor, ou do Passivo (`2.`) com saldo devedor, exceto contas redutoras (descrição começando com `"(-)"`, que são esperadas ter o sinal oposto ao grupo) **e exceto toda conta descendente de uma conta redutora** (`_indices_descendentes_de_conta_redutora()`, rodada 144 — bug real, ver abaixo): se a conta "mãe" (sintética, em qualquer nível acima, não só o pai direto) começa com `"(-)"`, o sinal "invertido" dos analíticos dentro dela é o comportamento esperado da própria natureza daquela conta, não uma inconsistência. 5. **`lucro_balancete_diverge_dre`** (alta, rodada seguinte) — o resultado do exercício (lucro **ou** prejuízo) precisa ser o mesmo valor no Balancete e na DRE, sem tolerância. Lê `CODIGO_LUCRO_PREJUIZO_EXERCICIO` (`"2.04.13.002"`, código de classificação fixo pra linha sintética "LUCROS/PREJUÍZOS DO EXERCÍCIO" dentro do Patrimônio Líquido — calibrado contra os 2 balancetes reais já em produção, mesmo padrão de risco de `CODIGO_CAIXA`/`indicadores.CODIGO_*`; agrega "LUCROS DO EXERCÍCIO" ou "(-) PREJUÍZOS DO EXERCÍCIO" conforme o resultado do mês), negado (mesma convenção Passivo/PL com sinal invertido de `balanceamento_ativo_passivo`) contra `linhas_dre[-1].valor` (última linha da DRE — mesma fonte que `_ContabilDadosIndicadores.resultado_liquido` em `views.py` já usa pros indicadores). Validado batendo exato contra os 2 balancetes reais antes de entrar em produção. 6. **`conta_transitoria_com_saldo`** (média) — descrição contém "TRANSIT" com `saldo_atual != 0`, **exceto** a palavra isolada "TRANSITO" (`\bTRANSITO\b`, "dinheiro em trânsito" — conceito diferente de conta transitória/de compensação, excluído numa rodada seguinte). A exclusão é por palavra isolada, não um trecho maior como "TRANSITOR": testando contra `1751 - Balancete 07.2026.pdf` antes de decidir, a fonte embutida corrompe o acento de "TRANSITÓRIA" num caractere ilegível (não recuperável) na extração — "TRANSITOR" nunca bateria com essa conta (que tem saldo real), então a mudança pra um trecho positivo mais longo foi descartada a favor de manter "TRANSIT" e só excluir o falso positivo conhecido. 7. **`conta_deveria_zerar`** (média) — `TRECHOS_CONTA_DEVERIA_ZERAR` (lista curta e deliberadamente restrita — só "ADIANTAMENTOS DE SALÁRIOS", que o ITD confirma dever ficar zerada todo mês; **não** inclui "Adiantamento de Férias"/"13º Salário", que legitimamente carregam saldo entre meses). @@ -41,6 +41,12 @@ Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, li **Duas regras removidas na mesma rodada** (pedido explícito do usuário, "utilizar a análise vertical" no lugar delas): `variacao_atipica_saldo` (variação de saldo de conta do Balancete contra a apuração anterior) — a Análise Vertical do PDF só cobre linhas da DRE, não contas do Balancete, então não tinha como reaproveitar a mesma fonte pra ela, e a alternativa de mantê-la como estava (usando `historico`) foi descartada a favor de simplificar o motor; `percentual_custo_receita_atipico` (razão Custos/Receita Líquida do mês contra o mês anterior, via `historico`) — a granularidade maior de `variacao_atipica_dre` sobre a Análise Vertical já cobre esse caso (e qualquer outra linha da DRE) sem precisar de uma regra dedicada. +**Bug real, rodada 144 — `saldo_sinal_invertido` disparava pra conta descendente de uma redutora, não só a filha direta**: usuário reportou, com print de achados reais ("Conta do Passivo 'DANITHI LTDA'"/"'MPASARABIAHOLDINGPARTICIPAÇÕES E'" com saldo devedor, ambas `2.04.01.003.001`), que o filtro de conta redutora (`_eh_conta_redutora()`, checa se a própria descrição começa com `"(-)"`) só excluía a conta que **em si** tem o prefixo — não bastava a "mãe" (sintética, em qualquer nível acima, não só o pai direto) ter o sinal `"(-)"`: as duas contas do print são analíticas dentro de `2.04.01.003` `"(-) CAPITALA INTEGRALIZAR"` (sic — espaço grudado, mesma classe de artefato de extração já documentada em "PDF de fonte atípica" acima; confirmado contra a apuração real `1751`/TAROBA em produção), então herdam o sinal devedor esperado da conta-mãe, mas não tinham `"(-)"` na própria descrição — geravam achado indevido. + +Corrigido com `_indices_descendentes_de_conta_redutora(contas)` (novo, `regras.py`) — calcula, pra toda a árvore de uma vez, quais índices têm **algum** ancestral redutora, usando o mesmo algoritmo de pilha de níveis já usado em todo o resto da aplicação pra árvore de contas (`codigo.count(".")` + ordem de leitura do PDF, mesmo espírito de `dcUltimaLevaVisivel()`/`_contabil_arvore_contexto()`) — **não** comparação de prefixo de código: o Questor reaproveita o mesmo código de classificação entre contas analíticas irmãs (mesmo problema já documentado em `ContabilObservacao.chave_conta()`), então string matching por código não seria confiável pra achar o pai; a pilha de níveis segue a ordem/profundidade real da árvore impressa no PDF, funciona mesmo com códigos repetidos entre irmãos. `regra_saldo_sinal_invertido()` passou a pular toda conta cujo índice está nesse conjunto, além da checagem já existente na própria conta. + +Validado de duas formas: (1) árvore sintética reproduzindo exatamente a estrutura de um exemplo do usuário (conta "(-) LUCROS DISTRIBUÍDOS" com 2 sócios dentro) — confirmado que o achado deixa de ser gerado com a correção, e que **seria** gerado sem ela (não um teste vazio por acidente); (2) rodado contra as **3 apurações reais já em produção** — a apuração `1751`/TAROBA (a mesma do print do usuário) tem exatamente os 2 achados dos sócios suprimidos, e as 3 apurações somadas têm 32 contas identificadas como "descendente de redutora" (a maioria contas de depreciação acumulada, `1.02.05.007.*`, mesmo padrão "(-) DEPREC. ..."), sem nenhum falso positivo óbvio nos nomes. **Não afeta achados já persistidos** — os 2 achados reais da apuração `1751` (`id=20`/`21`, ainda `pendente` no banco) só somem da tela depois que essa apuração for reprocessada (`_contabil_recria_achados()`, rodada 143, roda o motor de regras de novo do zero); não foi feita nenhuma edição direta no banco pra removê-los manualmente. + ## Models (`portal_api/models.py`) Padrão cabeçalho → linhas de detalhe → achados (mesma filosofia de `IndicadorApuracao`/`IndicadorApuracaoColaborador`): diff --git a/portal_api/dashboard_contabil/regras.py b/portal_api/dashboard_contabil/regras.py index 5109a73..d57b7e3 100644 --- a/portal_api/dashboard_contabil/regras.py +++ b/portal_api/dashboard_contabil/regras.py @@ -172,10 +172,43 @@ def _eh_conta_redutora(conta: LinhaBalanceteExtraida) -> bool: return conta.descricao.strip().startswith("(-)") +def _indices_descendentes_de_conta_redutora(contas: list[LinhaBalanceteExtraida]) -> set[int]: + """Índices (posição em `contas`, mesma ordem de leitura do PDF) de toda + conta que tem algum ANCESTRAL sintético (não só o pai direto) com + descrição começando em "(-)" — pedido explícito do usuário: se a conta + "mãe" tem o sinal de redutora, o saldo "invertido" dos filhos é o + comportamento esperado, não uma inconsistência (ex.: "(-) LUCROS + DISTRIBUÍDOS" é uma conta do Passivo, mas devedora por natureza — as + contas analíticas dentro dela, um sócio por linha, herdam esse mesmo + sinal e não deveriam gerar achado de `regra_saldo_sinal_invertido`). + + Usa o mesmo algoritmo de nível/hierarquia já usado em toda a aplicação + pra árvore de contas (`codigo.count(".")` + ordem de leitura do PDF, ver + `_contabil_arvore_contexto()`/`dcContaNivel()`), **não** comparação de + prefixo de código — o Questor reaproveita o mesmo código de classificação + entre contas analíticas irmãs (ver `ContabilObservacao.chave_conta()`), + então string matching por código não seria confiável pra identificar o + pai; a pilha de níveis, sim, já que segue estritamente a ordem/profundidade + real da árvore impressa no PDF.""" + niveis = [conta.codigo.count(".") for conta in contas] + pilha: list[tuple[int, bool]] = [] # (nível, é redutora OU descende de uma) + descendentes: set[int] = set() + for i, conta in enumerate(contas): + nivel = niveis[i] + while pilha and pilha[-1][0] >= nivel: + pilha.pop() + heranca = pilha[-1][1] if pilha else False + if heranca: + descendentes.add(i) + pilha.append((nivel, heranca or _eh_conta_redutora(conta))) + return descendentes + + def regra_saldo_sinal_invertido(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: achados = [] - for conta in atual.contas: - if conta.tipo != "A" or conta.saldo_atual == 0 or _eh_conta_redutora(conta): + descendentes_de_redutora = _indices_descendentes_de_conta_redutora(atual.contas) + for i, conta in enumerate(atual.contas): + if conta.tipo != "A" or conta.saldo_atual == 0 or _eh_conta_redutora(conta) or i in descendentes_de_redutora: continue if conta.codigo.startswith("1.") and conta.saldo_atual < 0: achados.append(