# 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.