"""Motor de regras de auditoria do Dashboard Contábil — cada `regra_*` é uma função pura (`ResultadoExtracao` da apuração atual + histórico já persistido da mesma empresa → `list[AchadoDetectado]`), sem tocar no ORM. Cobrem só o que é derivável do próprio balancete/DRE anexado (ver CLAUDE.md do pacote) — qualquer checagem que dependa de sistemas externos (Questor, extratos, folha) fica fora de propósito, por decisão do usuário (ferramenta analítica, não operacional).""" from __future__ import annotations from datetime import date from decimal import Decimal from .modelos import AchadoDetectado, LinhaBalanceteExtraida, ResultadoExtracao, SnapshotHistorico from .parser import LINHA_DRE_CUSTOS_TOTAIS, LINHA_DRE_RECEITA_LIQUIDA SEVERIDADE_ALTA = "alta" SEVERIDADE_MEDIA = "media" SEVERIDADE_BAIXA = "baixa" TOLERANCIA_CENTAVOS = Decimal("0.05") # Trechos de descrição que, segundo o roteiro de conferência (ITD-FISCO-7513), # devem ficar zeradas todo mês — ex.: "o saldo de adiantamento de salários e # empréstimos a funcionários deve estar zerado todos os meses". Deliberadamente # restrito a contas cujo saldo residual é sempre um sinal de pendência (não # inclui, por exemplo, "ADIANTAMENTOS DE FÉRIAS"/"13º SALÁRIO", que legitimamente # carregam saldo entre um mês e o pagamento seguinte). TRECHOS_CONTA_DEVERIA_ZERAR = ["ADIANTAMENTOS DE SALÁRIOS", "ADIANTAMENTO DE SALÁRIOS"] VARIACAO_LIMIAR_PERCENTUAL = Decimal("0.5") # 50% VARIACAO_VALOR_MINIMO = Decimal("1000") # ignora variações abaixo disso, mesmo que %-mente grandes def _mes_consecutivo(competencia_atual: date, competencia_anterior: date) -> bool: return (competencia_atual.year * 12 + competencia_atual.month) - ( competencia_anterior.year * 12 + competencia_anterior.month ) == 1 def _isola_mes_dre(valor_ytd_atual: Decimal, valor_ytd_anterior: Decimal | None, competencia_atual: date) -> Decimal: """A DRE do relatório é acumulada desde janeiro — isola o mês corrente subtraindo o acumulado do mês anterior, exceto em janeiro (onde o acumulado já é o próprio mês).""" if competencia_atual.month == 1 or valor_ytd_anterior is None: return valor_ytd_atual return valor_ytd_atual - valor_ytd_anterior def regra_balanceamento_ativo_passivo(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: ativo = next((c for c in atual.contas if c.codigo == "1"), None) passivo = next((c for c in atual.contas if c.codigo == "2"), None) if ativo is None or passivo is None: return [] diferenca = ativo.saldo_atual + passivo.saldo_atual # passivo já vem negativo no relatório if abs(diferenca) <= TOLERANCIA_CENTAVOS: return [] return [ AchadoDetectado( regra="balanceamento_ativo_passivo", severidade=SEVERIDADE_ALTA, titulo="Ativo não bate com Passivo", mensagem=( f"Saldo do Ativo (R$ {ativo.saldo_atual}) não coincide com o do Passivo " f"(R$ {-passivo.saldo_atual}) — diferença de R$ {abs(diferenca)}." ), valor_referencia=diferenca, ) ] def regra_debito_credito_divergente(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: raizes = [c for c in atual.contas if "." not in c.codigo] soma_debito = sum((c.debito for c in raizes), Decimal(0)) soma_credito = sum((c.credito for c in raizes), Decimal(0)) diferenca = soma_debito - soma_credito if abs(diferenca) <= TOLERANCIA_CENTAVOS: return [] return [ AchadoDetectado( regra="debito_credito_divergente", severidade=SEVERIDADE_ALTA, titulo="Débito total diferente do crédito total", mensagem=( f"Soma de débitos do período (R$ {soma_debito}) não bate com a soma de " f"créditos (R$ {soma_credito}) — diferença de R$ {abs(diferenca)}." ), valor_referencia=diferenca, ) ] def regra_saldo_negativo_caixa(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: achados = [] for conta in atual.contas: if conta.codigo.startswith("1.01.01.001") and conta.saldo_atual < 0: achados.append( AchadoDetectado( regra="saldo_negativo_caixa", severidade=SEVERIDADE_ALTA, titulo="Saldo de caixa negativo", mensagem=f'Conta "{conta.descricao}" ({conta.codigo}) com saldo negativo de R$ {conta.saldo_atual}.', codigo_conta=conta.codigo, valor_referencia=conta.saldo_atual, ) ) return achados def regra_conta_transitoria_com_saldo(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: achados = [] for conta in atual.contas: descricao_normalizada = conta.descricao.upper() if ("TRANSIT" in descricao_normalizada) and conta.saldo_atual != 0: achados.append( AchadoDetectado( regra="conta_transitoria_com_saldo", severidade=SEVERIDADE_MEDIA, titulo="Conta transitória com saldo", mensagem=f'Conta transitória "{conta.descricao}" ({conta.codigo}) deveria estar zerada e está com R$ {conta.saldo_atual}.', codigo_conta=conta.codigo, valor_referencia=conta.saldo_atual, ) ) return achados def regra_conta_deveria_zerar(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: achados = [] for conta in atual.contas: descricao_normalizada = conta.descricao.upper() if conta.saldo_atual == 0: continue if any(trecho in descricao_normalizada for trecho in TRECHOS_CONTA_DEVERIA_ZERAR): achados.append( AchadoDetectado( regra="conta_deveria_zerar", severidade=SEVERIDADE_MEDIA, titulo="Conta que deveria estar zerada", mensagem=f'Conta "{conta.descricao}" ({conta.codigo}) normalmente fica zerada todo mês e está com saldo de R$ {conta.saldo_atual}.', codigo_conta=conta.codigo, valor_referencia=conta.saldo_atual, ) ) return achados def _eh_conta_redutora(conta: LinhaBalanceteExtraida) -> bool: return conta.descricao.strip().startswith("(-)") 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): continue if conta.codigo.startswith("1.") and conta.saldo_atual < 0: achados.append( AchadoDetectado( regra="saldo_sinal_invertido", severidade=SEVERIDADE_MEDIA, titulo="Conta do Ativo com saldo credor", mensagem=f'Conta do Ativo "{conta.descricao}" ({conta.codigo}) está com saldo credor de R$ {-conta.saldo_atual}.', codigo_conta=conta.codigo, valor_referencia=conta.saldo_atual, ) ) elif conta.codigo.startswith("2.") and conta.saldo_atual > 0: achados.append( AchadoDetectado( regra="saldo_sinal_invertido", severidade=SEVERIDADE_MEDIA, titulo="Conta do Passivo com saldo devedor", mensagem=f'Conta do Passivo "{conta.descricao}" ({conta.codigo}) está com saldo devedor de R$ {conta.saldo_atual}.', codigo_conta=conta.codigo, valor_referencia=conta.saldo_atual, ) ) return achados def regra_descricao_generica(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: achados = [] for conta in atual.contas: if conta.descricao.strip().upper() == "DIVERSOS" and conta.saldo_atual != 0: achados.append( AchadoDetectado( regra="descricao_generica", severidade=SEVERIDADE_BAIXA, titulo='Conta com descrição genérica ("DIVERSOS")', mensagem=f'Conta {conta.codigo} está descrita apenas como "DIVERSOS", com saldo de R$ {conta.saldo_atual} — recomenda-se detalhar em conta analítica própria.', codigo_conta=conta.codigo, valor_referencia=conta.saldo_atual, ) ) return achados def regra_variacao_atipica_saldo(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: if not historico: return [] anterior = historico[0] achados = [] for conta in atual.contas: if conta.tipo != "A": continue saldo_anterior = anterior.contas.get(conta.codigo) if saldo_anterior is None or saldo_anterior == 0: continue diferenca = conta.saldo_atual - saldo_anterior if abs(diferenca) < VARIACAO_VALOR_MINIMO: continue variacao_percentual = abs(diferenca) / abs(saldo_anterior) if variacao_percentual < VARIACAO_LIMIAR_PERCENTUAL: continue achados.append( AchadoDetectado( regra="variacao_atipica_saldo", severidade=SEVERIDADE_MEDIA, titulo="Variação atípica de saldo", mensagem=( f'Conta "{conta.descricao}" ({conta.codigo}) variou {variacao_percentual:.0%} em relação a ' f"{anterior.competencia:%m/%Y} (de R$ {saldo_anterior} para R$ {conta.saldo_atual})." ), codigo_conta=conta.codigo, valor_referencia=diferenca, ) ) return achados def regra_variacao_atipica_dre(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: if len(historico) < 2: return [] anterior, retrasado = historico[0], historico[1] if not _mes_consecutivo(anterior.competencia, retrasado.competencia): return [] achados = [] for linha in atual.linhas_dre: valor_anterior_ytd = anterior.linhas_dre.get(linha.descricao) valor_retrasado_ytd = retrasado.linhas_dre.get(linha.descricao) if valor_anterior_ytd is None: continue mes_atual = _isola_mes_dre(linha.valor, valor_anterior_ytd, atual.cabecalho.periodo_fim) mes_anterior = _isola_mes_dre(valor_anterior_ytd, valor_retrasado_ytd, anterior.competencia) if mes_anterior == 0: continue diferenca = mes_atual - mes_anterior if abs(diferenca) < VARIACAO_VALOR_MINIMO: continue variacao_percentual = abs(diferenca) / abs(mes_anterior) if variacao_percentual < VARIACAO_LIMIAR_PERCENTUAL: continue achados.append( AchadoDetectado( regra="variacao_atipica_dre", severidade=SEVERIDADE_MEDIA, titulo="Variação atípica na DRE", mensagem=( f'Linha "{linha.descricao}" da DRE variou {variacao_percentual:.0%} em relação ao mês anterior ' f"(de R$ {mes_anterior} para R$ {mes_atual})." ), valor_referencia=diferenca, ) ) return achados def regra_percentual_custo_receita_atipico( atual: ResultadoExtracao, historico: list[SnapshotHistorico] ) -> list[AchadoDetectado]: if len(historico) < 2: return [] anterior, retrasado = historico[0], historico[1] if not _mes_consecutivo(anterior.competencia, retrasado.competencia): return [] def _linha(resultado_linhas: dict[str, Decimal], nome: str) -> Decimal | None: return resultado_linhas.get(nome) linhas_atual = {l.descricao: l.valor for l in atual.linhas_dre} receita_atual_ytd = _linha(linhas_atual, LINHA_DRE_RECEITA_LIQUIDA) custos_atual_ytd = _linha(linhas_atual, LINHA_DRE_CUSTOS_TOTAIS) receita_anterior_ytd = _linha(anterior.linhas_dre, LINHA_DRE_RECEITA_LIQUIDA) custos_anterior_ytd = _linha(anterior.linhas_dre, LINHA_DRE_CUSTOS_TOTAIS) receita_retrasado_ytd = _linha(retrasado.linhas_dre, LINHA_DRE_RECEITA_LIQUIDA) custos_retrasado_ytd = _linha(retrasado.linhas_dre, LINHA_DRE_CUSTOS_TOTAIS) if None in (receita_atual_ytd, custos_atual_ytd, receita_anterior_ytd, custos_anterior_ytd): return [] receita_mes_atual = _isola_mes_dre(receita_atual_ytd, receita_anterior_ytd, atual.cabecalho.periodo_fim) custos_mes_atual = _isola_mes_dre(custos_atual_ytd, custos_anterior_ytd, atual.cabecalho.periodo_fim) receita_mes_anterior = _isola_mes_dre(receita_anterior_ytd, receita_retrasado_ytd, anterior.competencia) custos_mes_anterior = _isola_mes_dre(custos_anterior_ytd, custos_retrasado_ytd, anterior.competencia) if receita_mes_atual == 0 or receita_mes_anterior == 0: return [] pct_atual = abs(custos_mes_atual) / abs(receita_mes_atual) pct_anterior = abs(custos_mes_anterior) / abs(receita_mes_anterior) if pct_anterior == 0: return [] variacao_percentual = abs(pct_atual - pct_anterior) / pct_anterior if variacao_percentual < VARIACAO_LIMIAR_PERCENTUAL: return [] return [ AchadoDetectado( regra="percentual_custo_receita_atipico", severidade=SEVERIDADE_MEDIA, titulo="Percentual de custo sobre receita fora do padrão da empresa", mensagem=( f"O custo representou {pct_atual:.1%} da receita líquida do mês, contra {pct_anterior:.1%} no mês " f"anterior — variação de {variacao_percentual:.0%} em relação ao padrão histórico desta empresa." ), valor_referencia=pct_atual - pct_anterior, ) ] REGRAS = [ regra_balanceamento_ativo_passivo, regra_debito_credito_divergente, regra_saldo_negativo_caixa, regra_conta_transitoria_com_saldo, regra_conta_deveria_zerar, regra_saldo_sinal_invertido, regra_descricao_generica, regra_variacao_atipica_saldo, regra_variacao_atipica_dre, regra_percentual_custo_receita_atipico, ] def gera_achados(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: """`historico` deve vir ordenado da apuração mais recente pra mais antiga (mesma empresa, competências anteriores à de `atual`).""" achados: list[AchadoDetectado] = [] for regra in REGRAS: achados.extend(regra(atual, historico)) return achados