40 KiB
Dashboard Contábil (Relatórios > Contabilidade)
Movido do
CLAUDE.mdda raiz — este arquivo é carregado automaticamente ao trabalhar dentro deportal_api/dashboard_contabil/. VerCLAUDE.mdna 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.
O botão "Gerar Dashboard" (relatório final em HTML para o administrador da empresa) já está implementado — ver "Relatório 'Gerar Dashboard'" abaixo, que inclui exportação de Balancete/DRE em XLSX a partir dele (exportacao.py, ver "Exportação em XLSX"). Ainda fora de escopo: consolidação entre várias empresas/competências ao mesmo tempo (o BI Contábil externo que este Dashboard substitui tem filtros "Ano-Mês"/"Empresa-Filial" que sugerem isso, mas o escopo confirmado com o usuário é sempre uma apuração por vez — ver seção própria).
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 naturalcodigo_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 deresto(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 dox0do primeiro caractere da linha, em relação ao menorx0visto na seção (a raiz, nível 0);totalizadoréTruequando algum caractere da linha usa fonte em negrito (fontnamecontendo"bold", case-insensitive) — confirmado contra o PDF real: linhas como "RECEITA OPERACIONAL BRUTA"/"(-) CUSTOS TOTAIS"/"(=) LUCRO BRUTO" usamTimes-Bold, as demaisTimes-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 emparser.py) guardam o texto exato dessas duas linhas totalizadoras (com o prefixo"(=) "/"(-) "que o Questor imprime) — usadas porregra_percentual_custo_receita_atipicopra 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á quecodigo_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 deIndicadorApuracao, 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).
balanceamento_ativo_passivo(alta) — soma do grupo Ativo (codigo="1") deve fechar com a do Passivo (codigo="2", já vem negativo no relatório).debito_credito_divergente(alta) — soma de Débito das contas-raiz (codigosem 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 contra792 - balancete 072026.pdf(débito total = crédito total = R$ 416.271.243,32 nas contas-raiz).saldo_negativo_caixa(alta) — conta comcodigocomeçando em1.01.01.001(grupo Caixa) esaldo_atual < 0.conta_transitoria_com_saldo(média) — descrição contém "TRANSIT" (cobre "TRANSITÓRIA"/"TRANSITORIA") comsaldo_atual != 0.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).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).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").variacao_atipica_saldo(média) — comparasaldo_atualde cada conta analítica contra a apuração anterior da mesma empresa (porcodigo), sinaliza quando a variação passa deVARIACAO_LIMIAR_PERCENTUAL(50%) eVARIACAO_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.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.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_togetheremcodigo_empresa+competencia— reprocessar a mesma competência de uma empresa exige excluir a apuração antiga primeiro (sem "reabrir"/reprocessar nesta v1, diferente deImportacaoPlanoSaude).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 emcreate(), nunca é apagado — só muda destatus(pendente/tratado/ignorado), sempre comobservacao_contadorobrigatória ao mudar de pendente (mesmo espírito de "histórico completo preservado" deImportacaoPlanoSaudeAuditoria).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():
- Lê o arquivo inteiro pra memória (
arquivo.read()) — nada em disco ainda. dashboard_contabil_pipeline.processa_apuracao(io.BytesIO(conteudo), _contabil_monta_historico)— extrai o cabeçalho/contas/DRE e, com ocodigo_empresa/competenciajá 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. CapturaContabilExtracaoInvalidaError→ 400 genérico.- 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ó doIntegrityErrordo banco, que devolveria um 500 cru). - Só agora, dentro de
transaction.atomic(), criaContabilApuracao(grava o arquivo viaContentFile(conteudo, ...)) +bulk_createde contas/linhas DRE/achados.except Exceptionfora dowithapaga o arquivo gravado se algo falhar no meio (upload deFileFieldnã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" (#dc-gerar-dashboard-btn) chama pidGerarDashboardContabil() — ver "Relatório 'Gerar Dashboard'" abaixo.
Balancete e DRE usam a mesma árvore recolhível (dashboard-contabil.js): o Balancete já construía uma árvore expansível a partir do nível de indentação derivado do código de classificação (dcContaNivel(), contando segmentos separados por .) — a DRE não tem código de classificação (ver "Extração do PDF" acima), mas já carregava nivel pronto do backend (ContabilLinhaDre.nivel, derivado do x0 de cada linha no PDF), então renderDre() reaproveita exatamente o mesmo algoritmo de renderContas() (pilha de níveis recolhidos, "tem filhos" = a próxima linha tem nível maior) só que sobre linha.nivel direto, sem precisar de um dcContaNivel equivalente. Reaproveita as mesmas classes CSS do toggle (.dc-conta-toggle/.dc-conta-toggle-spacer/.dc-conta-desc-cell, dashboard-contabil.css) — o nome genérico ("conta") já cobre as duas árvores, não precisou de classe nova. Estado de colapso é independente por aba (dcContasColapsadas/dcDreColapsadas, dois Set() reiniciados juntos em renderRevisao()).
Card de achado expande a conta usada no apontamento (renderAchados(), pedido explícito do usuário): achado.conta (id, já vem no payload de /api/contabil-apuracoes/{id}/, junto de conta_codigo/conta_descricao) é resolvido pra objeto completo procurando em apuracaoAtual.contas (.find((c) => c.id === achado.conta)) — sem chamada de API extra, já que a apuração inteira (contas + linhas de DRE + achados) já vem de uma vez só nesse endpoint. Só achados vinculados a uma conta específica ganham o botão "Ver conta usada no apontamento" (.dc-achado-card__toggle-conta) — as duas regras gerais (balanceamento_ativo_passivo/debito_credito_divergente, conta nulo no model) não têm uma conta única por trás, então não mostram o toggle. Expandido, mostra código/descrição/saldo anterior/débito/crédito/saldo atual da conta (.dc-achado-card__conta, um <dl> em grid). Estado de expansão (dcAchadosContaExpandida, um Set() de ids de achado) segue o mesmo padrão de dcContasColapsadas/dcDreColapsadas — reiniciado em renderRevisao().
Lista de Observações ordenada por severidade (pedido explícito do usuário): achadosFiltrados() filtra e depois ordena (PID_DC_SEVERIDADE_ORDEM = {alta: 0, media: 1, baixa: 2}) — Alta sempre primeiro, Baixa por último, preservando a ordem original (ordem em que as regras rodaram) dentro de uma mesma severidade, já que Array.prototype.sort é estável. Puramente ordenação de exibição no frontend, nada mudou no backend/model.
Resumo clicável (donut por severidade + cards por categoria) no topo da aba Observações (.dc-achados-resumo, dashboard-contabil.html/.css/.js, pedido explícito do usuário, inspirado numa tela de auditoria de outro sistema — ver rodadas 97/98 do CHANGELOG.md): acima dos chips de filtro, um donut em SVG puro (sem Chart.js — essa dependência só existe no relatório estático "Gerar Dashboard", não faz sentido carregar aqui numa tela interativa pequena) com a quantidade de observações por severidade (Alta/Média/Baixa, mesmas cores dos badges — --danger/--gold/rgb(var(--slate-rgb))) e o total no centro, mais uma grade de cards, um por regra/categoria de auditoria. PID_DC_REGRAS (constante no topo do arquivo) enumera as 10 regras de regras.py por chave (achado.regra, campo que já existia no model ContabilAchado) — sempre as 10, mesmo as que não geraram nenhum achado nesta apuração, cada uma com "Nenhum apontamento encontrado" nesse caso (mesmo espírito do sistema de referência, que também lista checagens que passaram). Cada card mostra a contagem e uma mini-tabela com código+descrição da conta (quando achado.conta está preenchido — mesmas 6 regras que já preenchem conta, ver "Card de achado expande a conta usada no apontamento" acima) ou "Geral" (as 4 regras sem conta específica: balanceamento, débito/crédito, variação da DRE, percentual custo/receita) + o status (.dc-badge, mesmo padrão dos badges já existentes). renderAchadosResumo() (chamada no início de renderAchados(), então atualiza sozinha a cada mudança de status/filtro) conta sempre sobre apuracaoAtual.achados completo, nunca sobre achadosFiltrados() — é uma visão geral estável, não deve mudar quando o usuário filtra a lista detalhada logo abaixo. As 10 cores das categorias (PID_DC_CATEGORIA_CORES) reaproveitam os tokens --accent-rgb/--danger-rgb/--gold-rgb/--teal-rgb/--slate-rgb/--coral-rgb de tokens.css (já theme-aware, acompanham tema claro/escuro e a cor de tema escolhida pelo usuário) completadas até 10 com color-mix(in srgb, ... , white/black), sem nenhum hex novo hardcoded. Decisão explícita de escopo (alinhada por pergunta ao usuário antes de implementar): não foi replicado o checklist pass/fail de checagens do sistema de referência (várias delas — folha, vencimento de fornecedor/cliente/imposto, saldo bancário — dependem de dado fora do Balancete/DRE anexado, fora do escopo já documentado desta ferramenta, ver "Decisões de escopo" acima) nem essa visualização foi levada pro relatório "Gerar Dashboard" (só a tela de revisão do Portal).
Donut e cards são clicáveis, filtram a lista detalhada abaixo (pedido explícito do usuário, rodada 98): cada fatia do donut (ou item da legenda) chama pidDcSelecionarSeveridade(severidade) — a mesma função que os chips "Alta"/"Média"/"Baixa" já usavam (extraída pra função reaproveitável, sem duplicar a lógica de atualizar filtroSeveridade+classe .is-active+renderAchados()); clicar num card de categoria alterna filtroRegra (novo estado, achado.regra exata ou null) — clicar de novo no mesmo card limpa o filtro. achadosFiltrados() ganhou uma terceira condição (filtroRegra) que se combina por E lógico com severidade/status já existentes — os três filtros funcionam juntos, não um substitui o outro. Como não existe um chip próprio pro filtro por categoria, uma faixa nova (#dc-regra-filtro-ativo, escondida quando filtroRegra é null) aparece entre os chips e a lista mostrando o nome da categoria ativa + um botão "Limpar" — sem essa faixa não haveria como o usuário perceber por que a lista ficou filtrada nem como sair do filtro sem adivinhar que precisa clicar de novo no card. Clicar em qualquer um dos dois (donut/card) também dá um scrollIntoView suave até #dc-achados-list (pidDcScrollParaLista()), já que o resumo pode empurrar a lista pra fora da tela em telas menores. O donut usa a técnica clássica de pizza/donut em SVG com <circle r="15.9155"> (circunferência ≈ 100, então stroke-dasharray/stroke-dashoffset já funcionam direto em unidades de percentual, sem precisar de pathLength) — cada segmento é um <circle> próprio com seu stroke-dashoffset acumulado (offset inicial 25 desloca o início de "3 horas" pra "12 horas"), clicável individualmente porque pointer-events de SVG só considera pixel realmente pintado pelo stroke, sem precisar de hit-test manual por ângulo.
Relatório "Gerar Dashboard" (indicadores.py + dashboard-contabil-relatorio.html)
ContabilApuracaoViewSet.dashboard() (GET /api/contabil-apuracoes/{id}/dashboard/, mesma permissão de toggle único das outras actions) gera um documento HTML autocontido (não estende o shell do Portal — nunca a marca "P.I.D.", ver "Logos" no CLAUDE.md raiz) com os indicadores financeiros, a DRE/Balancete agrupados por nível (recolhível, ver "Árvore recolhível" abaixo), um gráfico de evolução do Resultado Líquido e as observações que o contador já registrou (contas, linhas de DRE, achados tratados/ignorados com observacao_contador). Escopo confirmado com o usuário: sempre uma apuração por vez (a que está sendo revisada), sem "Filial" (não existe no modelo) nem consolidação entre empresas — isso ficaria pra um BI à parte, fora de escopo aqui.
É GET, não POST (diferente do padrão /gerar/ de outras ferramentas, que mutam estado ou recebem multipart) — decisão de uma rodada seguinte, corrigindo um bug real: a primeira versão era POST, e o frontend chamava via fetch + URL.createObjectURL(blob) + window.open(url) pra abrir numa nova aba (mesmo padrão de pidGerarArquivoPlanoSaude). Só que um documento carregado de uma URL blob: tem uma origem sintética — URLs relativas dentro do HTML (como as que {% static %} gera, ex. /static/img/logo-branco.png) não resolvem de forma confiável contra a origem real do Portal nesse contexto, e a logo do escritório no cabeçalho simplesmente não carregava. Com GET, o frontend abre a URL da API direto (window.open(/api/contabil-apuracoes/${id}/dashboard/, "_blank"), pidGerarDashboardContabil() em dashboard-contabil.js) — navegação de verdade, mesma origem, sem blob nenhum de permeio; {% static %} funciona igual a qualquer outra página do Portal. Também simplificou o JS (sem pidEnsureCsrfCookie/CSRF manual — GET não precisa).
Indicadores financeiros (indicadores.py)
Funções puras, mesmo espírito de regras.py — não tocam no ORM, recebem os dados já extraídos. Os grupos do Balancete são identificados por código de classificação fixo (CODIGO_*), calibrado contra o balancete de referência do usuário (792 - balancete 072026.pdf) — mesma decisão de risco já aceita em regra_saldo_negativo_caixa (código fixo "1.01.01.001" pro grupo Caixa). Se um cliente usar uma numeração de plano de contas diferente da vista até agora, os indicadores desse cliente saem errados silenciosamente — revisar contra mais balancetes reais de outras empresas antes de confiar cegamente no valor exibido ao cliente.
Códigos calibrados: Ativo Total "1", Ativo Circulante "1.01", Estoques "1.01.08", Imobilizado "1.02.05", Depreciação Acumulada "1.02.05.007", Passivo Total "2", Passivo Circulante "2.01", Patrimônio Líquido "2.04". O Passivo Não Circulante/Exigível a Longo Prazo não tem código calibrado — não aparece no balancete de referência, que não tem dívida de longo prazo — e é calculado por eliminação (Passivo Total − Passivo Circulante − Patrimônio Líquido), sempre exato pela identidade contábil, sem depender de adivinhar mais um código.
Validados byte a byte contra a captura de tela do Balancete anexada pelo usuário (Ativo Total 2.721.721,59 / Ativo Circulante 2.582.105,87 / Estoques 1.702.326,05 / Passivo Circulante 408.765,09 / Patrimônio Líquido 2.312.956,50 / Imobilizado 134.203,88 — bateram exatamente):
- Liquidez Corrente = Ativo Circulante / Passivo Circulante → 6,32
- Liquidez Seca = (Ativo Circulante − Estoques) / Passivo Circulante → 2,15
- Composição do Endividamento = Passivo Circulante / Exigível Total → 100,00% (bate porque essa empresa não tem Passivo Não Circulante)
- Grau de Endividamento = Exigível Total / Patrimônio Líquido → 17,67%
- IPL (Imobilização do Patrimônio Líquido) = Imobilizado / Patrimônio Líquido → 5,80%
Aproximado, sem exemplo real pra validar: Liquidez Geral = Ativo Circulante / Exigível Total — trata o Realizável a Longo Prazo como indisponível/0, porque o Ativo Não Circulante ("1.02") hoje mistura Investimentos/Imobilizado com um eventual Realizável a Longo Prazo, sem separar (o parser não distingue isso). Coincide com a Liquidez Corrente quando a empresa não tem Passivo Não Circulante (era o caso do balancete de referência).
DRE: resultado_liquido é passado pra calcula_indicadores() já resolvido pela view (linhas_dre[-1].valor, a última linha na ordem do relatório) — não por texto, mais seguro. EBIT = Resultado Líquido − linha "(+/-) Despesas/Receitas Financeiras" (casada por substring via LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRAS em parser.py, não validada contra um PDF real como LINHA_DRE_RECEITA_LIQUIDA/LINHA_DRE_CUSTOS_TOTAIS já são). Depreciação/Amortização do mês = variação do saldo da conta de depreciação acumulada entre a apuração atual e a anterior da mesma empresa — indisponível na primeira apuração de uma empresa (mesmo espírito das regras 8-10 de auditoria, que também dependem de histórico); EBITDA = EBIT + essa variação, também indisponível quando ela for. ROA = Resultado Líquido / Ativo Total; ROE = Resultado Líquido / Patrimônio Líquido.
Kanitz (Termômetro de Insolvência) usa a fórmula-livro-texto padrão (FI = 0,05×ROE + 1,65×LiquidezGeral + 3,55×LiquidezSeca − 1,06×LiquidezCorrente − 0,33×GrauEndividamento), não validada contra o BI antigo que este Dashboard substitui — o valor mostrado numa captura do usuário ("11,31") está fora da faixa clássica do índice (−7 a +7), sugerindo que o BI antigo usa uma variação/escala diferente. O relatório marca esse card como estimativa; ajustar se o usuário trouxer a fórmula exata usada pelo BI antigo.
Todo indicador que pode ficar indisponível é Decimal | None no dataclass IndicadoresFinanceiros — None significa indisponível, nunca é tratado como zero (os filtros de template descritos abaixo respeitam essa distinção).
Template (templates/dashboard-contabil-relatorio.html) e filtros (portal_api/templatetags/contabil_extras.py)
Documento HTML autocontido — <style> inline com a mesma paleta já usada nos documentos gerados (indicadores/recibo.py: roxo #3d2178, dourado #b4872a), cabeçalho com static/img/logo-branco.png (texto branco — não logo.png, que ficaria ilegível sobre o banner roxo escuro, ver "Logos em static/img/" no CLAUDE.md raiz). Botão "Imprimir" (onclick="window.print()") escondido via @media print.
Conteúdo dividido em 3 abas (.dcr-tabs/.dcr-tab/[data-dcr-panel], JS puro inline no próprio template — não reaproveita .pa-tabs de perfis-acesso.css, que não é carregado neste documento autocontido), nesta ordem pedida pelo usuário: Balancete (aba inicial) → D.R.E. → Indicadores (os dois grupos de cards + o gráfico de evolução, que fazem mais sentido juntos). Na impressão (@media print), a barra de abas some e as 3 ficam visíveis ao mesmo tempo, cada uma numa página própria (page-break-after) — documento impresso não deve esconder conteúdo atrás de uma aba não clicada.
Observações espalhadas nas 3 abas, cada uma com o recorte certo (pedido explícito do usuário — antes ficavam todas juntas numa única seção fora das abas): a aba Balancete termina com "Observações do Balancete" (só observacoes_contas); a aba D.R.E. termina com "Observações da D.R.E." (só observacoes_dre); a aba Indicadores termina com "Todas as Observações da Análise" (observacoes_contas + observacoes_dre + achados_com_observacao juntos, cada item com um prefixo indicando a origem — "Balancete — ...", "D.R.E. — ...", "Auditoria — ..." — já que aqui não há mais uma aba própria pra inferir o contexto). Nunca "Achado"/"Achado de Auditoria" em texto visível — pedido explícito do usuário, mesmo motivo pelo qual a aba de revisão já se chama "Observações" (data-dc-tab="achados" com o texto "Observações", dashboard-contabil.html) e não "Achados"; achado/ContabilAchado/achados_com_observacao continuam normais como nome de variável/model/classe, só não podem aparecer como palavra na tela. As três seções reaproveitam o mesmo markup (.dcr-obs-lista/.dcr-obs-item), só filtrando quais das três listas do contexto (observacoes_contas/observacoes_dre/achados_com_observacao, já vindas prontas de views.py) cada uma itera — nenhuma mudança no backend foi necessária, é só reorganização do template. Estado vazio próprio por seção ("Nenhuma observação registrada no Balancete."/"...na D.R.E."/"...nesta análise.").
Balancete e D.R.E. têm árvore recolhível igual à tela de revisão (pedido explícito do usuário — a primeira versão do relatório vinha totalmente expandida, sem toggle). Diferença de arquitetura em relação a renderContas()/renderDre() em dashboard-contabil.js: lá é uma SPA que re-renderiza a tabela inteira a cada clique; aqui é HTML estático gerado uma vez, então o nível de cada linha e se ela "tem filhos" (nivel/tem_filhos) são calculados no servidor (_contabil_arvore_contexto() em views.py, mesmo algoritmo — "tem filhos" = a próxima linha tem nível maior) e ficam como atributos data-dcr-nivel/data-dcr-tem-filhos/data-dcr-id em cada <tr> já renderizada. pidDcrArvore(tbodyId) (JS inline no template) só alterna o atributo hidden das <tr> existentes com a mesma lógica de pilha de níveis recolhidos, sem reconstruir HTML nenhum. Tudo nasce expandido (nenhum recolhido por padrão). Na impressão, .dcr-tabela tbody tr[hidden] { display: table-row !important; } força toda linha a aparecer mesmo que o usuário tenha recolhido algum grupo na tela — documento impresso não deve esconder conta nenhuma atrás de um grupo recolhido.
Gráfico de evolução do Resultado Líquido via Chart.js (<script src="https://cdn.jsdelivr.net/npm/chart.js@4">, snippet oficial recomendado pela biblioteca) — série injetada com {{ evolucao|json_script:"dcr-evolucao-data" }} (usa DjangoJSONEncoder, que já serializa Decimal/date sem precisar de nenhum encoder customizado) e lida em JS puro. Inicializado sob demanda, não no carregamento da página: o <canvas> nasce dentro da aba "Indicadores", escondida por padrão (Balancete é a aba inicial), e Chart.js não desenha corretamente num canvas com largura/altura zero — inicializaGrafico() só roda no primeiro clique nessa aba (ou no evento beforeprint, pra garantir que o gráfico exista na versão impressa mesmo que a aba nunca tenha sido aberta na tela).
portal_api/templatetags/contabil_extras.py — primeiro uso de template tags customizadas no projeto (precisou de portal_api/templatetags/__init__.py, auto-descoberto pelo Django por portal_api já estar em INSTALLED_APPS). Filtros moeda/percentual/indice/competencia, todos no padrão brasileiro (separador de milhar ., decimal ,) — mesmo espírito do helper _moeda() que já existe, duplicado por arquivo, nos dois geradores de PDF (indicadores/recibo.py, custo_contratacao/pdf.py), mas como filtro reaproveitável, já que este template tem tabelas inteiras de valores monetários (Balancete/DRE), não um valor por vez. None sempre vira "—", nunca "R$ 0,00"/"0,00%" — ver acima por quê. Um quinto filtro, numero_bruto, existe só pra alimentar a animação de contagem dos cards (ver "Visual" abaixo) — devolve o valor cru (str(float(valor)), "" se None) exclusivamente para um atributo data-count, nunca pro texto exibido.
Visual: fontes, animações e contagem animada dos cards
Pedido explícito do usuário — "deixar mais bonito, complexo, dinâmico, com animações fluidas". Fontes "Manrope" (títulos/cards/abas) + "Inter" (corpo, font-variant-numeric: tabular-nums nas colunas de valor) via Google Fonts. Paleta estendida da mesma família roxo/dourado já usada nos documentos gerados, com gradiente + glow radial sutil no cabeçalho (.dcr-header::before, @keyframes dcrGlow) e no fundo da página.
Regra de ouro pra toda animação de entrada aqui: nunca fixar opacity:0/transform como estilo estático fora de um @keyframes — só via animation: nome duração easing both;. Isso garante que @media print { * { animation: none !important; } } sozinho já basta pra devolver o elemento ao estado normal (visível, posição natural) na impressão, sem precisar de um reset explícito por seletor — se alguma animação nova for adicionada aqui, seguir essa mesma disciplina, senão a impressão pode sair com conteúdo em branco. O restante do @media print já existente (abas somem, todas as 3 aparecem juntas, linhas recolhidas forçadas a aparecer) continua igual.
Cards ganharam data-count/data-final/data-format/data-color-rule (ver pidDcrAnimaContadores()): a contagem anima de 0 até o valor com requestAnimationFrame/easing, formatando os quadros intermediários com Intl/toLocaleString("pt-BR", ...) nativo do navegador — mas o texto final escrito ao fim da animação é sempre data-final, a mesma string que os filtros Django já geraram (nunca um valor recalculado em JS, só decoração da transição). Cor por sinal/threshold só onde é seguro sem inventar limiar nenhum: data-color-rule="sign" (ROA/ROE/EBIT/EBITDA — positivo verde, negativo vermelho, convenção universal) e data-color-rule="liquidez" (Liquidez Corrente/Seca/Geral — verde se ≥ 1, âmbar/vermelho se < 1, também convenção padrão de mercado). Kanitz, Composição/Grau de Endividamento e IPL não ganham cor nenhuma — não existe um limiar validado pra eles nesta implementação (ver "Fórmulas" acima), então colorir feito "bom"/"ruim" daria uma falsa segurança num número que o próprio card já avisa ser estimativa/aproximação.
Cards e gráfico só existem/animam depois da aba "Indicadores" ser aberta pela primeira vez (mesmo motivo do gráfico: canvas com tamanho zero não desenha certo, e contar um número invisível não faz sentido). Isso criou um risco real de impressão: se o usuário nunca abrir essa aba e mandar imprimir direto, a contagem começaria do zero bem na hora que o navegador captura a página. pidDcrAnimaContadores(true) (parâmetro instantaneo) resolve isso — o único listener de beforeprint do documento decide entre inicializar tudo já no valor final (se a aba nunca foi aberta) ou só finalizar uma contagem já em andamento (finalizadores, um array de callbacks que força cada card pro texto final) — nunca as duas coisas competindo (era um bug real de uma versão intermediária desta mesma rodada, corrigido antes do usuário testar: dois listeners de beforeprint separados podiam disparar uma animação nova bem na hora de imprimir, sem tempo de terminar).
Abas ganharam um indicador deslizante (.dcr-tabs__indicator, getBoundingClientRect-like via offsetLeft/offsetWidth, recalculado no resize e no load — texto de fonte customizada pode mudar a largura da aba depois do primeiro paint). Árvore recolhível do Balancete/D.R.E. ganhou um fade rápido só nas linhas que acabaram de aparecer (.dcr-row-in, reflow forçado via void tr.offsetWidth pra poder reiniciar a animação a cada clique), não a tabela inteira — evita flicker num clique que só afeta um grupo pequeno.
Frontend (static/js/dashboard-contabil.js)
pidGerarDashboardContabil(id) é só window.open(/api/contabil-apuracoes/${id}/dashboard/, "_blank") — navegação direta, sem fetch/blob/CSRF nenhum (ver por que isso importa em "É GET, não POST" acima). Bem mais simples do que o padrão de pidGerarArquivoPlanoSaude (importacao-plano-saude.js), que precisa de fetch manual porque /gerar/ ali é POST e devolve um arquivo pra download, não uma página pra navegar.
Exportação em XLSX (exportacao.py)
Pedido explícito do usuário: um botão "Exportar XLSX" dentro do relatório "Gerar Dashboard", um por seção (Balancete/D.R.E.), pra baixar aquela tabela em planilha. ContabilApuracaoViewSet.exportar_xlsx() (GET /api/contabil-apuracoes/{id}/exportar-xlsx/?parte=balancete ou ?parte=dre, mesma permissão de toggle único das outras actions) monta as linhas já com o nível de indentação calculado (mesma fórmula de dashboard(): conta.codigo.count(".") pro Balancete, max(0, linha.nivel) pra DRE) e chama dashboard_contabil.exportacao.gera_xlsx_balancete()/gera_xlsx_dre() — funções puras (openpyxl, sem tocar no ORM, mesmo espírito de indicadores.py/regras.py) que recebem dataclasses (LinhaBalanceteXlsx/LinhaDreXlsx) já prontas, não os models do Django.
Cada planilha nasce com cabeçalho (título/empresa/CNPJ/competência, linhas 1-3, mescladas), uma linha de cabeçalho de colunas com fundo roxo (#3d2178, mesma paleta dos outros documentos gerados pelo escritório — indicadores/recibo.py/custo_contratacao/pdf.py, nunca a marca "P.I.D." do Portal) e a tabela de dados a partir da linha 6, com freeze_panes logo abaixo do cabeçalho de colunas. Conta sintética (Balancete, tipo="S") e linha totalizadora (DRE, totalizador=True) ganham negrito + um fundo dourado claro (#f6ecd4), mesmo destaque visual que essas linhas já têm na tela de revisão e no relatório HTML. A hierarquia (nível de indentação) vira Alignment(indent=nivel) na célula de descrição — não dá pra reproduzir o toggle recolher/expandir de uma planilha, então a árvore sempre nasce "totalmente expandida" (mesmo espírito do relatório HTML impresso). Colunas monetárias usam number_format = '"R$" #,##0.00' (valor gravado como float, não como texto formatado — continua editável/somável no Excel).
Botão "Exportar XLSX" (.dcr-export-btn, dashboard-contabil-relatorio.html) é um <a href="/api/contabil-apuracoes/{{ apuracao.id }}/exportar-xlsx/?parte=..."> puro — sem JS nenhum, mesmo espírito de link direto de download; o browser já lida com o Content-Disposition: attachment da resposta. Escondido em @media print junto do botão "Imprimir" (.dcr-print-btn), já que exportar não faz sentido numa versão impressa.
Nova consulta de histórico (_contabil_monta_historico_completo, views.py)
Diferente de _contabil_monta_historico (capada em 2 apurações anteriores, usada só pelas regras de auditoria no create()), esta busca todo o histórico da empresa — usada só pelo gráfico de evolução e pelo cálculo de Depreciação/Amortização do relatório. Recebe codigo_empresa/competencia_atual diretamente (não um CabecalhoExtraido) porque roda contra uma ContabilApuracao já persistida, ao contrário de _contabil_monta_historico (que roda durante o create(), antes de qualquer coisa existir no banco).