portal_publico/portal_api/dashboard_contabil/CLAUDE.md

14 KiB

Dashboard Contábil (Relatórios > Contabilidade)

Movido do CLAUDE.md da raiz — este arquivo é carregado automaticamente ao trabalhar dentro de portal_api/dashboard_contabil/. Ver CLAUDE.md na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.).

Ferramenta que otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (processo descrito em ITD-FISCO-7513, "Roteiro de Conferência de Balancete"): o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor hoje enviado ao cliente), a ferramenta extrai as contas/linhas e roda um conjunto de checagens automáticas de auditoria, apresentando os achados numa tela de revisão onde o contador analisa, registra observações e conclui a análise. Permissão de toggle único (apps["dashboard-contabil"] em permissoes["relatorios"], subgrupo "Contabilidade"), checada via PermissaoApp("relatorios", "dashboard-contabil") em todos os ModelViewSet relacionados. Nasce restrita só ao perfil "Inovação" (código 8) — mesmo padrão de "Não Conformidades" (ver override em seed_portal.py), já que expõe balancete/DRE completos dos clientes.

v1: só execução, auditoria e análise. O botão "Gerar Dashboard HTML" (que geraria o relatório final em HTML para o administrador da empresa, com exportação em XLSX) existe na tela mas fica desabilitado — decisão explícita do usuário, "vamos primeiro estruturar a parte de execução, auditoria e análise". Sem endpoint de backend correspondente ainda.

Decisões de escopo (confirmadas com o usuário)

  • Entrada: só PDF. O Questor também exporta Balancete/DRE em XLSX estruturado (mais confiável de extrair, sem o risco de parsing de texto), levantado como alternativa — o usuário optou por manter só PDF, como pedido originalmente. Não há suporte a XLSX nesta ferramenta.
  • Histórico para variação mês a mês fica no próprio Portal, não depende do relatório "Análise Comparativa Mensal"/"Acompanhamento Mensal" (que é só um relatório complementar, derivado do balancete, enviado ao cliente separadamente — não é upload desta ferramenta). Cada apuração processada fica salva (ContabilApuracao, chave natural codigo_empresa+competencia), e as regras de variação comparam contra as apurações anteriores da mesma empresa já no banco.
  • Escopo das regras: só o que é derivável do próprio balancete/DRE anexado — nenhuma checagem do ITD que dependa de sistemas externos (Questor, extratos bancários, folha de pagamento, PID legado). A ferramenta é analítica ("Auditoria de Balancetes" em Auditorias > Fisco/Contábil, hoje só um placeholder href="#" no menu, referencia o antigo sistema PID legado — não confundir com este Dashboard Contábil, são coisas diferentes), não substitui as etapas operacionais do roteiro (zeramento de saldos etc.).

Extração do PDF (parser.py)

O relatório Questor de Balancete + DRE tem uma particularidade de renderização que quebra a extração padrão do pdfplumber: cada caractere do texto real é desenhado em posição própria (sem kerning) e, por baixo dele, o PDF inclui uma grade densa de caracteres de espaço cobrindo toda a largura de cada linha — um artefato do gerador de relatório, não intencional para leitura. Isso faz page.extract_words()/page.extract_text() do pdfplumber tratarem esses espaços "de fundo" como separadores de palavra reais, quebrando números em dígitos isolados (ex.: "34.245.469,57" vira uma sequência de tokens '3', '4', '.', '2'...).

_reconstroi_linhas() contorna isso trabalhando direto com page.chars: ignora todo caractere de texto igual a " " e reconstrói cada linha a partir da posição real (x0/x1) dos caracteres não-espaço, reinserindo um único espaço só quando o vão horizontal entre dois caracteres consecutivos ultrapassa _GAP_ESPACO (0.8pt) — calibrado contra 792 - balancete 072026.pdf (arquivo de referência do usuário, salvo fora do repositório em Projetos\Balancetes): o vão dentro de uma palavra/número é ~0, entre duas palavras da mesma descrição é ~1.7-1.9pt, e entre campos da tabela (conta → flag S/A → código → descrição, ou entre colunas de valor) é sempre ≥5pt. Essa reconstrução foi validada rodando de fato contra o PDF real antes de escrever o parser definitivo (nunca desenhar regex só de texto colado — mesmo cuidado documentado na skill importacao-plano-saude).

  • Balancete: cada linha casa com _RE_LINHA_BALANCETE (^(conta)\s+(S)?\s*(código)\s+(resto)$), e os últimos 4 tokens monetários de resto (via _RE_MONETARIO) são Saldo Anterior/Débito/Crédito/Saldo Atual, nessa ordem — o texto antes deles é a descrição. tipo é "S" (sintética) quando o flag aparece, "A" (analítica) quando não.
  • DRE: cada linha é descrição + um único valor final (sem código de classificação, diferente do Balancete). nivel (indentação) é derivado do x0 do primeiro caractere da linha, em relação ao menor x0 visto na seção (a raiz, nível 0); totalizador é True quando algum caractere da linha usa fonte em negrito (fontname contendo "bold", case-insensitive) — confirmado contra o PDF real: linhas como "RECEITA OPERACIONAL BRUTA"/"(-) CUSTOS TOTAIS"/"(=) LUCRO BRUTO" usam Times-Bold, as demais Times-Roman.
  • Extração para no início da seção "Demonstração Mensal (Análise Vertical)" (páginas finais do mesmo PDF, quando presentes) — de propósito: o histórico próprio do Portal cobre a mesma necessidade de forma mais confiável (qualquer competência anterior já processada, não só as últimas 3 meses que aquele relatório mostra).
  • LINHA_DRE_RECEITA_LIQUIDA/LINHA_DRE_CUSTOS_TOTAIS (constantes em parser.py) guardam o texto exato dessas duas linhas totalizadoras (com o prefixo "(=) "/"(-) " que o Questor imprime) — usadas por regra_percentual_custo_receita_atipico pra achar a linha certa por texto, mais robusto a pequenas variações de nível/indentação entre empresas do que confiar só no negrito.
  • extrai_balancete_dre(origem) aceita tanto um caminho em disco quanto um arquivo já aberto em memória (io.BytesIO) — a view chama isto antes de salvar qualquer coisa no banco, já que codigo_empresa/competencia (a chave natural da apuração) só são conhecidos depois de ler o PDF, não informados pelo usuário no upload (diferente de IndicadorApuracao, que recebe a competência como campo do formulário).

Regras de auditoria v1 (regras.py)

Cada regra_* é uma função pura: (ResultadoExtracao da apuração atual, list[SnapshotHistorico]) -> list[AchadoDetectado]. historico vem ordenado da apuração mais recente pra mais antiga (só as últimas 2 já persistidas da mesma empresa, resolvidas por _contabil_monta_historico() em views.py).

  1. balanceamento_ativo_passivo (alta) — soma do grupo Ativo (codigo="1") deve fechar com a do Passivo (codigo="2", já vem negativo no relatório).
  2. debito_credito_divergente (alta) — soma de Débito das contas-raiz (codigo sem ponto, ou seja só "1" e "2") deve bater com a soma de Crédito. 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. conta_transitoria_com_saldo (média) — descrição contém "TRANSIT" (cobre "TRANSITÓRIA"/"TRANSITORIA") com saldo_atual != 0.
  5. 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).
  6. 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).
  7. descricao_generica (baixa) — descrição exatamente "DIVERSOS" com saldo relevante (o ITD cita esse caso especificamente: "o contador deverá realocar estes lançamentos a conta pertinente").
  8. variacao_atipica_saldo (média) — compara saldo_atual de cada conta analítica contra a apuração anterior da mesma empresa (por codigo), sinaliza quando a variação passa de VARIACAO_LIMIAR_PERCENTUAL (50%) e VARIACAO_VALOR_MINIMO (R$1.000, evita ruído em contas de valor irrisório). Só roda a partir da 2ª apuração de uma empresa.
  9. variacao_atipica_dre (média) — mesma ideia, mas isolando o mês: como a DRE do relatório é acumulada desde janeiro, _isola_mes_dre() subtrai o acumulado do mês anterior (valor_ytd_atual - valor_ytd_anterior), exceto em janeiro (onde o acumulado já é o próprio mês). Só roda quando há 2 apurações anteriores consecutivas (_mes_consecutivo()) — precisa isolar tanto o mês atual quanto o anterior pra comparar mês-contra-mês de verdade, não acumulado-contra-acumulado.
  10. percentual_custo_receita_atipico (média) — compara o percentual Custos/Receita Líquida do mês isolado atual contra o do mês isolado anterior (mesma técnica da regra 9) — o ITD é explícito que não existe parâmetro geral entre empresas pra essa relação, só comparação histórica da própria empresa.

Regras 8-10 não geram achado nenhum na primeira apuração de uma empresa (sem histórico ainda) — comportamento esperado, não bug.

Models (portal_api/models.py)

Padrão cabeçalho → linhas de detalhe → achados (mesma filosofia de IndicadorApuracao/IndicadorApuracaoColaborador):

  • ContabilApuracao: codigo_empresa/nome_empresa/cnpj/competencia (extraídos do PDF, não informados no upload) + periodo_inicio/periodo_fim + arquivo + status (revisao/concluida). unique_together em codigo_empresa+competencia — reprocessar a mesma competência de uma empresa exige excluir a apuração antiga primeiro (sem "reabrir"/reprocessar nesta v1, diferente de ImportacaoPlanoSaude).
  • ContabilConta: uma linha do Balancete. observacao (TextField, editável via PATCH em qualquer conta, tenha ela gerado achado ou não) — é o espaço de "análise" pedido pelo usuário, independente da auditoria automática.
  • ContabilLinhaDre: uma linha da DRE, sem código de classificação (o relatório não traz um pra DRE, diferente do Balancete).
  • ContabilAchado: achado de auditoria, nasce automático em create(), nunca é apagado — só muda de status (pendente/tratado/ignorado), sempre com observacao_contador obrigatória ao mudar de pendente (mesmo espírito de "histórico completo preservado" de ImportacaoPlanoSaudeAuditoria). conta é nullable — achados 1 e 2 (balanceamento/débito-crédito) são gerais, sem uma conta específica.

ContabilApuracaoViewSet.create() — ordem de operações não-trivial

Diferente de IndicadorApuracaoViewSet/ImportacaoPlanoSaudeViewSet (onde a chave natural do registro, ex. competencia, vem do formulário do usuário), aqui codigo_empresa/competencia só são conhecidos depois de extrair o PDF. A ordem em create():

  1. Lê o arquivo inteiro pra memória (arquivo.read()) — nada em disco ainda.
  2. dashboard_contabil_pipeline.processa_apuracao(io.BytesIO(conteudo), _contabil_monta_historico) — extrai o cabeçalho/contas/DRE e, com o codigo_empresa/competencia já em mãos, chama _contabil_monta_historico() (função injetada, consulta o ORM) pra buscar até 2 apurações anteriores da mesma empresa, então roda as regras. Captura ContabilExtracaoInvalidaError → 400 genérico.
  3. Confere se já existe uma apuração pra essa empresa+competência (.exists()) → 400 com mensagem específica, antes de qualquer escrita (evita depender só do IntegrityError do banco, que devolveria um 500 cru).
  4. Só agora, dentro de transaction.atomic(), cria ContabilApuracao (grava o arquivo via ContentFile(conteudo, ...)) + bulk_create de contas/linhas DRE/achados. except Exception fora do with apaga o arquivo gravado se algo falhar no meio (upload de FileField não é transacional).

pipeline.processa_apuracao(origem, busca_historico) recebe busca_historico como uma função (não uma lista já pronta) exatamente por essa dependência: a chave de busca do histórico só existe depois da extração, então não dá pra pré-buscar antes de chamar o pipeline como as outras duas ferramentas fazem.

Frontend

templates/dashboard-contabil.html (page-content--wide) segue o padrão de 3 sub-views de indicador-desempenho.html: #dc-list-view (histórico + botão "Nova Análise") / #dc-form-view (upload de um único PDF — sem campo de competência, é extraído do arquivo) / #dc-review-view (abas Achados/Balancete/DRE, via .pa-tabs/.pa-tab-panel de perfis-acesso.css). Achados têm filtro por severidade e por status (pendentes/todos); tratar/ignorar um achado abre um modal próprio (#dc-achado-modal) que exige observação não-vazia; observação de conta abre outro modal (#dc-observacao-modal), sem essa exigência (pode ficar em branco). Botão "Gerar Dashboard HTML" existe (#dc-gerar-dashboard-btn) mas fica disabled com title="Em desenvolvimento" — sem chamada de API nenhuma atrás dele nesta v1.