diff --git a/.claude/settings.json b/.claude/settings.json index f26af79..8545763 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -150,7 +150,8 @@ "PowerShell($f = \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\portal_api\\\\dashboard_contabil\\\\CHANGELOG.md\"; \\(Get-Content $f | Measure-Object -Line\\).Lines)", "PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" manage.py makemigrations portal_api --name contabil_ordering_determinista --dry-run -v 2)", "PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" manage.py makemigrations portal_api --name contabil_ordering_determinista)", - "PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\33b72ffc-a7b3-4b7e-8afe-bb65121e9a85\\\\scratchpad\\\\testa_removidos.py\")" + "PowerShell(& \"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\33b72ffc-a7b3-4b7e-8afe-bb65121e9a85\\\\scratchpad\\\\testa_removidos.py\")", + "PowerShell(.venv\\\\Scripts\\\\python.exe manage.py migrate portal_api 0080)" ], "additionalDirectories": [ "C:\\Users\\Depaula\\AppData\\Local\\Temp\\claude\\c--Users-Depaula-Documents-Portal\\3ea0ee22-e5fd-4030-98b1-ee2e71c16ce0\\scratchpad\\halloween-design", diff --git a/CLAUDE.md b/CLAUDE.md index 73bd38d..4e028a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -342,7 +342,7 @@ Ver `portal_api/nao_conformidades/CLAUDE.md`. Chamado de "Dashboard Contábil" até uma rodada anterior — renomeado pra "Relatório Contábil" a pedido explícito do usuário, pra soar como um aliado do trabalho do contador em vez de mais um processo/sistema novo (rename só de rótulo visível: menu, título da página, cabeçalhos e o botão que gera o relatório; nomes técnicos internos — pasta do pacote, arquivos, classes de model, rotas — continuam `dashboard_contabil`/`dashboard-contabil.*`/`Contabil*`/`/contabil-*`, sem nenhuma mudança). -Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (`ITD-FISCO-7513`): o contador anexa o PDF de Balancete + DRE (modelo Questor, mesmo relatório hoje enviado ao cliente), a ferramenta extrai as contas e roda um motor de regras de auditoria (saldos negativos, contas transitórias/genéricas com saldo, contas que deveriam ficar zeradas, débito ≠ crédito, variações atípicas mês a mês contra o histórico já processado no Portal), com revisão de achados e observações por conta antes da conclusão (a observação fica no histórico da empresa + conta e reaparece assinada nas competências seguintes, até ser encerrada) — cada conta/linha também ganha um checkbox de "validado" (marcador informativo de que o contador já conferiu aquele item) e um editor de observação inline na própria tabela (não mais um popup), com um toggle "mostrar ao cliente" que nasce desmarcado por padrão. O botão "Gerar Relatório" gera um relatório HTML autocontido (indicadores financeiros, gráfico de evolução, DRE/Balancete agrupados, observações do contador — com a marca do escritório, não a "P.I.D." do Portal), com exportação de Balancete/DRE em XLSX a partir dele. **Ainda fora de escopo**: consolidação entre várias empresas/competências ao mesmo tempo (substituiria o BI Contábil por completo). +Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (`ITD-FISCO-7513`): o contador anexa o PDF de Balancete + DRE (modelo Questor ou Contabit, detectado sozinho, mesmo relatório hoje enviado ao cliente; no Contabit o código da empresa vem do nome do arquivo e a seção de indicadores fica oculta), a ferramenta extrai as contas e roda um motor de regras de auditoria (saldos negativos, contas transitórias/genéricas com saldo, contas que deveriam ficar zeradas, débito ≠ crédito, variações atípicas mês a mês contra o histórico já processado no Portal), com revisão de achados e observações por conta antes da conclusão (a observação fica no histórico da empresa + conta e reaparece assinada nas competências seguintes, até ser encerrada) — cada conta/linha também ganha um checkbox de "validado" (marcador informativo de que o contador já conferiu aquele item) e um editor de observação inline na própria tabela (não mais um popup), com um toggle "mostrar ao cliente" que nasce desmarcado por padrão. O botão "Gerar Relatório" gera um relatório HTML autocontido (indicadores financeiros, gráfico de evolução, DRE/Balancete agrupados, observações do contador — com a marca do escritório, não a "P.I.D." do Portal), com exportação de Balancete/DRE em XLSX a partir dele. **Ainda fora de escopo**: consolidação entre várias empresas/competências ao mesmo tempo (substituiria o BI Contábil por completo). Ver `portal_api/dashboard_contabil/CLAUDE.md`. diff --git a/portal_api/dashboard_contabil/CHANGELOG.md b/portal_api/dashboard_contabil/CHANGELOG.md index ebd5fa2..0cccfbf 100644 --- a/portal_api/dashboard_contabil/CHANGELOG.md +++ b/portal_api/dashboard_contabil/CHANGELOG.md @@ -462,3 +462,37 @@ Validado com `manage.py check` + `makemigrations --check` (estado de migração Limite conhecido e aceito: o aviso vale para o reprocessamento em que a remoção aconteceu. Como todo achado é recriado do zero e a linha já não está no banco, reprocessar de novo com o mesmo arquivo não repete o aviso — mesmo espírito de `alterada_reprocessamento`, que também marca a mudança daquela rodada. Mexe em `.py`: exige reiniciar o `runserver`. E exige `python manage.py migrate` antes de usar. + +### Rodada 152 — Segundo modelo de arquivo: Balancete + DRE do Contabit + +Pedido: além do Questor, parte dos clientes recebe o Balancete + DRE gerado pelo Contabit, e a aplicação precisa funcionar com esse modelo também. Arquivo de referência: `1512 - Balancete 082026.pdf` (em `Projetos\Balancetes\Contabit`, fora do repositório). + +Decisões do usuário, confirmadas antes de implementar: (1) o código da empresa, que o PDF do Contabit não traz, vem do prefixo numérico do nome do arquivo; o Questor continua lendo o código do PDF; (2) valores exibidos como no PDF (saldos pela natureza da conta, Passivo positivo), sem converter para a convenção do Questor; (3) empresas do Contabit não usam indicadores por enquanto, e a seção fica oculta; (4) estruturar com base neste único arquivo e ir testando conforme chegarem outros. + +- **Detecção automática**, sem escolha do usuário: `parser.extrai_balancete_dre()` olha a primeira página ("Classificação/Conta" + "PERÍODO DE ... À ...") e despacha para o módulo novo `parser_contabit.py`. Conversões e erros compartilhados foram para `conversao.py` (só para evitar import circular; `parser.py` reexporta `ExtracaoInvalidaError`). +- **Leitura do Balancete pela ordem do fluxo do PDF**, não por posição: descrição longa é cortada visualmente mas continua no texto, e seus caracteres ficam na mesma faixa do nº da conta (ordenar por `x0` produzia "GRAFICOS2 2L9T3D5A" para "GRAFICOS LTDA" + conta "22935"). Sintética = linha sem nº de conta (não existe flag S/A). `ContabilConta.conta_numero` passou a aceitar nulo. +- DRE e demonstração mensal agrupam caracteres com tolerância vertical (o valor das linhas em negrito vem 1pt abaixo do texto), descartam a coluna TOTAL e as linhas espaçadoras só com zero, e convertem "JUNHO/2026" para "jun/2026". +- **Regras**: balanceamento compara Ativo = Passivo (os dois positivos); caixa negativo usa `1.10.10.01`; lucro Balancete x DRE usa `2.40.40.20` sem negar; sinal invertido vira "analítica com saldo negativo" (premissa, sem exemplo real ainda: o Contabit imprimir o saldo contrário com "-"; se não imprimir, a regra só não dispara). As outras cinco não mudaram. +- **Campo novo `ContabilApuracao.leiaute`** (migração `0080`, `db_default="questor"`, porque o banco é o de produção e um processo com o código anterior não enviaria a coluna). Propriedade `tem_indicadores` esconde a seção de indicadores na aba "Dashboard" (botão "Gerenciar Indicadores" incluso), no relatório e no PDF do Resumo. +- Nome de arquivo sem código devolve 400 com mensagem própria (`CodigoEmpresaAusenteError`). A mensagem de reprocessar com empresa diferente passou a citar o código, que no Contabit é a única diferença visível. + +Validado: extração do PDF do Contabit (130 contas, todas fechando saldo anterior + débito − crédito = saldo atual pela natureza; Ativo = Passivo; débito = crédito; lucro do Balancete = DRE), simulações de saldo contrário, regressão contra os 15 PDFs Questor disponíveis (extração e apontamentos idênticos ao código anterior) e fluxo completo pela API com rollback (criar, abrir, Dashboard, relatório, PDF do Resumo, XLSX, reprocessar), com o arquivo gravado pelo teste apagado depois. + +Mexe em `.py`: exige reiniciar o `runserver`. A migração `0080` já foi aplicada. + +### Rodada 153 — Variações atípicas no fim da aba Análise Vertical, com botão de validar + +Pedido do usuário, com print da aba Análise Vertical: (1) os apontamentos de variação passam a se chamar "Variação atípica na DRE - Análise Vertical"; (2) as variações ficam listadas no fim da página da Análise Vertical, cada uma com um botão para confirmar que foi validada. Perguntado o que "validar" faz: trata o apontamento, e os textos funcionam como as observações das contas, com a opção de mostrar ao cliente. + +- Título novo em `regras.TITULO_VARIACAO_ATIPICA_DRE` e no rótulo da regra no JS; migração de dados `0081` renomeou os apontamentos já gravados (só `titulo`, a `mensagem` fica como está). +- Action nova `POST /api/contabil-achados/{id}/validar/` (`ContabilAchadoValidarSerializer`), só para `variacao_atipica_dre`: trata sem exigir `observacao_contador`, ou volta a pendente. As outras regras seguem com justificativa obrigatória. +- Seção nova no fim da aba (`renderVariacoesAnaliseVertical()`): card por variação com "Ver na tabela", a thread de observação da linha (a mesma da tabela, via `dcObsPainelConteudoHtml()` com escopo `variacao`) e o botão Validar/Validado. Na aba Observações, esses apontamentos trocaram "Revisar" pelo mesmo botão. + +Validado: `manage.py check`, `makemigrations --check`, endpoint testado com rollback (validar, desfazer, 400 em outra regra e em apuração concluída). O JS não pôde ser executado neste ambiente (sem Node nem navegador): conferir na tela. + +Mexe em `.py`: exige reiniciar o `runserver`. A migração `0081` já foi aplicada. + +**Ajustes na mesma rodada, depois do teste do usuário**: +- O primeiro teste deu 404 no validar. Não era bug: o `runserver` tinha carregado o `views.py` 5 segundos antes da gravação da action nova e o recarregamento automático se perdeu (4 processos `runserver` encadeados). Resolvido reiniciando o servidor. +- As variações saíram da aba Observações (lista, donut e card "Variações e Indicadores"), já que têm lista própria no fim da aba Análise Vertical (`dcAchadosDaAbaObservacoes()`). Com isso o botão Validar que tinha sido posto nos cards daquela aba também saiu. +- Linha da Análise Vertical com variação atípica ganhou um ícone antes do botão de validado (dourado pendente, teal validada). Hover mostra a variação e as observações da linha; clique leva ao card da variação com a thread aberta, para validar e comentar. diff --git a/portal_api/dashboard_contabil/CLAUDE.md b/portal_api/dashboard_contabil/CLAUDE.md index c92f199..1b77a19 100644 --- a/portal_api/dashboard_contabil/CLAUDE.md +++ b/portal_api/dashboard_contabil/CLAUDE.md @@ -6,12 +6,13 @@ **Nome**: "Relatório Contábil" é o rótulo visível ao usuário; internamente tudo continua `dashboard_contabil`/`dashboard-contabil.*`/`Contabil*`/`/api/contabil-*`/`apps["dashboard-contabil"]`. O rename foi só de texto visível (menu em `catalogo.py`, ``/`<h1>`/cabeçalhos dos dois templates, o botão "Gerar Relatório" e os `verbose_name` do admin) — pedido explícito do usuário, para a ferramenta soar como um aliado do trabalho do contador em vez de mais um sistema. **Não propagar esse rename para dentro do código.** -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 já enviado ao cliente), a ferramenta extrai as contas/linhas, roda um motor de regras de auditoria e apresenta os apontamentos numa tela de revisão, onde o contador analisa, registra observações e conclui a análise. No fim, gera um relatório HTML autocontido para o cliente. +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 já enviado ao cliente, gerado pelo **Questor** ou pelo **Contabit**, ver "Leiaute Contabit" abaixo), a ferramenta extrai as contas/linhas, roda um motor de regras de auditoria e apresenta os apontamentos numa tela de revisão, onde o contador analisa, registra observações e conclui a análise. No fim, gera um relatório HTML autocontido para o cliente. **Permissão**: toggle único `apps["dashboard-contabil"]` em `permissoes["relatorios"]` (subgrupo "Contabilidade"), checado via `PermissaoApp("relatorios", "dashboard-contabil")` em todos os ViewSets. **Nasce restrita ao perfil "Inovação"** (override em `seed_portal.py`), já que expõe balancete/DRE completos dos clientes. ## Decisões de escopo (confirmadas com o usuário) +- **Dois modelos de PDF, detectados sozinhos**: Questor e Contabit. O usuário nunca escolhe; `ContabilApuracao.leiaute` guarda o detectado. Ver "Leiaute Contabit" abaixo para o que muda. - **Entrada: só PDF.** O Questor também exporta Balancete/DRE em XLSX estruturado (mais confiável de extrair), levantado como alternativa e recusado pelo usuário. Não há suporte a XLSX na entrada. - **Sempre uma apuração por vez.** Sem consolidação entre empresas ou competências — isso seria um BI à parte. O BI Contábil externo que esta ferramenta substitui tem filtros "Ano-Mês"/"Empresa-Filial" que sugerem o contrário; o escopo confirmado é uma apuração por vez. - **O histórico para comparação mês a mês fica no próprio Portal.** Cada apuração processada fica salva (`ContabilApuracao`, chave natural `codigo_empresa`+`competencia`), e é contra ela que se compara. Não depende do relatório "Análise Comparativa Mensal"/"Acompanhamento Mensal", que é um relatório complementar enviado ao cliente por fora, não uma entrada desta ferramenta. @@ -44,6 +45,26 @@ A solução adotada é **avisar, não corrigir**: `ContabilApuracao.fonte_pdf_at Validado contra os 3 PDFs reais disponíveis: `True` só para o `1751`, `False` para `792` e `2017` (sem falso positivo nos já confirmados corretos). +## Leiaute Contabit (`parser_contabit.py`) + +`extrai_balancete_dre(origem, nome_arquivo)` abre o PDF e, se `parser_contabit.eh_leiaute_contabit(primeira página)` (cabeçalho com "Classificação/Conta" e "PERÍODO DE ... À ..."), devolve `parser_contabit.extrai(pdf, nome_arquivo)`; senão segue o caminho do Questor, que não mudou. As conversões (`para_decimal`/`para_percentual`/`para_data`) e os erros vivem em `conversao.py` só para evitar import circular; `parser.py` reexporta `ExtracaoInvalidaError`/`CodigoEmpresaAusenteError`. Calibrado contra um único arquivo real, `1512 - Balancete 082026.pdf` (em `Projetos\Balancetes\Contabit`, fora do repositório). **Decisão do usuário: estruturar com base neste documento e ir testando conforme chegarem outros**, então tratar os pontos marcados como premissa com a devida desconfiança. + +**Código da empresa vem do nome do arquivo** (decisão explícita do usuário): o PDF só traz nome e CNPJ. `codigo_empresa_do_nome_arquivo()` pega o prefixo numérico ("1512 - Balancete 082026.pdf" → `"1512"`, sem padding). Sem prefixo, `CodigoEmpresaAusenteError` (subclasse de `ExtracaoInvalidaError`), que a view devolve como 400 com mensagem própria (`_contabil_resposta_extracao_invalida()`). `create()`/`reprocessar()` passam `arquivo.name` ao pipeline; o Questor ignora o parâmetro. + +**Balancete lido na ordem do fluxo do PDF, não por posição.** A descrição longa é cortada na tela (clip), mas o texto inteiro continua no PDF e seus caracteres ocupam a mesma faixa horizontal do nº da conta e até do Saldo Anterior: ordenar por `x0` produz "GRAFICOS2 2L9T3D5A" para "GRAFICOS LTDA" + conta "22935". `_trechos_por_fluxo()` segue `pagina.chars` na ordem em que foram desenhados (descrição, depois os valores, depois o nº da conta) e corta um trecho quando o próximo caractere recua mais que o kerning explica (`_RECUO_MAXIMO_KERNING`) ou salta mais que um espaço (`_SALTO_MAXIMO_TRECHO`). Trecho só monetário é valor (os 4 são ordenados entre si por `x0`: Saldo Anterior, Débito, Crédito, Saldo Atual), trecho só dígitos é o nº da conta, o resto é classificação + descrição. Qualquer linha com classificação e contagem de colunas diferente de 4 valores levanta erro em vez de gravar algo desalinhado. + +- **Sem flag S/A**: sintética é a linha sem nº de conta (`conta_numero=None`, por isso `ContabilConta.conta_numero` aceita nulo e a tela mostra vazio). +- **Saldos pela natureza da conta**: Passivo e redutoras (depreciação acumulada, encargos a transcorrer) aparecem positivos, e o grupo pai faz a subtração. **Exibidos como no PDF** (decisão do usuário), sem converter para a convenção do Questor. Todas as 130 contas do arquivo de referência fecham `saldo anterior ± débito ∓ crédito = saldo atual` pela natureza. +- Os espaços entre palavras são caracteres reais neste PDF (não a grade de fundo do Questor), então `_texto_por_posicao()` os mantém e só colapsa repetições. + +**DRE ("DEMONSTRAÇÃO DO RESULTADO ACUMULADO NO ANO")**: negativo com "-" (não parênteses). O valor das linhas em negrito é desenhado 1pt abaixo do texto, então `_agrupa_linhas()` junta caracteres com tolerância vertical (`_TOLERANCIA_LINHA`) em vez de `round(top)`. Nível pelo recuo, passo `_PASSO_NIVEL_DRE` (7,45pt). A última linha é "LUCRO LIQUIDO DO EXERCICIO". + +**Demonstração mensal ("DEMONSTRAÇÃO DO RESULTADO / MENSAL")** alimenta a mesma Análise Vertical do Questor: meses por extenso ("JUNHO/2026" → `"jun/2026"`, `_MESES_ABREVIADOS`), AV% sem "%", uma coluna **TOTAL** a mais (soma dos meses mostrados, descartada) e linhas espaçadoras só com zeros, sem descrição (ignoradas). Passo de nível `_PASSO_NIVEL_MENSAL` (5,95pt). Os rótulos não são iguais aos da DRE acumulada ("RESULTADO BRUTO" x "LUCRO BRUTO") e linhas zeradas no trimestre são omitidas; nada no código depende das duas árvores serem idênticas. + +**Regras que mudam por leiaute** (todas as outras rodam igual): balanceamento compara Ativo = Passivo (sem somar); caixa negativo usa o prefixo `1.10.10.01` (`PREFIXO_CAIXA_CONTABIT`); lucro Balancete x DRE lê `2.40.40.20` (`CODIGO_LUCRO_PREJUIZO_EXERCICIO_CONTABIT`) sem negar; sinal invertido (`_saldo_sinal_invertido_contabit()`) é "analítica com saldo negativo", com a natureza (grupo, invertida por redutora/descendente de redutora) usada só no texto. **Premissa não confirmada**: o Contabit imprimir saldo contrário à natureza com "-"; se imprimir de outro jeito, a regra só deixa de disparar, sem falso positivo. + +**Sem indicadores** (decisão do usuário, por enquanto): os indicadores cadastrados usam códigos e rótulos do plano do Questor e sairiam errados. `ContabilApuracao.tem_indicadores` (`False` no Contabit) faz `_contabil_dados_resumo()` devolver `indicadores_grupos` vazio sem calcular, `indicadores()` devolver vazio, o relatório e o PDF do Resumo pularem a seção, e a aba "Dashboard" esconder os cards, a dica e o botão "Gerenciar Indicadores" (`renderDashboardTab()`). Para ligar indicadores no Contabit no futuro seria preciso mapear os códigos por leiaute nos componentes (`1.10` Circulante, `2.10` PC, `2.40` PL, `1.20.30` Imobilizado, `1.20.30.20` Depreciação; Estoques ainda sem exemplo). + ## Motor de regras de auditoria (`regras.py`) Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, list[SnapshotHistorico]) -> list[AchadoDetectado]`. @@ -62,7 +83,7 @@ Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, li 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). A exclusão é por palavra isolada e não por um trecho positivo mais longo como "TRANSITOR" porque, no PDF `1751`, a fonte corrompe o acento de "TRANSITÓRIA" num caractere não recuperável — "TRANSITOR" nunca casaria com essa conta, que tem saldo real. 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. 8. **`descricao_generica`** (baixa) — descrição exatamente `"DIVERSOS"` com saldo relevante (o ITD cita esse caso: "o contador deverá realocar estes lançamentos a conta pertinente"). -9. **`variacao_atipica_dre`** (baixa) — usa a seção "Demonstração Mensal (Análise Vertical)" do próprio PDF (`atual.linhas_analise_vertical`), não o histórico do Portal. Compara os **2 meses mais recentes** dessa tabela pelo `percentual` de cada linha sobre a Receita Operacional Bruta. Dispara quando o salto entre os 2 meses é de pelo menos `VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO` (**1 ponto percentual** — piso para não disparar em saltos percentualmente grandes só porque a base já era perto de zero) **e**, quando o percentual anterior não é zero, `VARIACAO_LIMIAR_PERCENTUAL` (**65%** de variação relativa). Roda mesmo na 1ª apuração de uma empresa, desde que o PDF traga a seção. +9. **`variacao_atipica_dre`** (baixa) — usa a seção "Demonstração Mensal (Análise Vertical)" do próprio PDF (`atual.linhas_analise_vertical`), não o histórico do Portal. Compara os **2 meses mais recentes** dessa tabela pelo `percentual` de cada linha sobre a Receita Operacional Bruta. Dispara quando o salto entre os 2 meses é de pelo menos `VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO` (**1 ponto percentual** — piso para não disparar em saltos percentualmente grandes só porque a base já era perto de zero) **e**, quando o percentual anterior não é zero, `VARIACAO_LIMIAR_PERCENTUAL` (**65%** de variação relativa). Roda mesmo na 1ª apuração de uma empresa, desde que o PDF traga a seção. Título "Variação atípica na DRE - Análise Vertical" (`TITULO_VARIACAO_ATIPICA_DRE`, pedido do usuário; a migração `0081` renomeou os já gravados). **Tratada de um jeito próprio, sem justificativa escrita**: ver "Variações atípicas no fim da aba Análise Vertical" abaixo. ### O 10º apontamento não é uma regra: conta/linha removida no reprocessamento @@ -96,7 +117,7 @@ Padrão cabeçalho → linhas de detalhe → apontamentos, mesma filosofia de `I ### Apuração e linhas -- **`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`. Mais: `analise_vertical_meses` (`JSONField`, ex. `["mai/2026", "jun/2026", "jul/2026"]`, lista compartilhada por toda a apuração), `fonte_pdf_atipica`, `resumo_fechamento` (texto rico do contador), `indicadores_ocultos` e `indicadores_selecionados` (`JSONField`, listas de chave de indicador). +- **`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`. Mais: `analise_vertical_meses` (`JSONField`, ex. `["mai/2026", "jun/2026", "jul/2026"]`, lista compartilhada por toda a apuração), `fonte_pdf_atipica`, `leiaute` (`questor`/`contabit`, detectado pelo parser; `db_default` no banco, não só `default` do Django, porque o banco é o de produção e um processo com código anterior ao campo não o enviaria) + a propriedade `tem_indicadores`, `resumo_fechamento` (texto rico do contador), `indicadores_ocultos` e `indicadores_selecionados` (`JSONField`, listas de chave de indicador). - **`ContabilConta`** — uma linha do Balancete. `conta_numero` (numeração interna do Questor) e `codigo` (classificação) são coisas diferentes, as duas extraídas. `validado` (`BooleanField`, checkbox informativo de "já conferi", sem gate em nada), `alterada_reprocessamento` + `valor_anterior_reprocessamento`. - **`ContabilLinhaDre`** — uma linha da DRE, sem código de classificação. Mesmos `validado`/`alterada_reprocessamento`/`valor_anterior_reprocessamento`. - **`ContabilLinhaAnaliseVertical`** — mesma árvore/descrição/nível da DRE, mas `valores` (`JSONField`) guarda um `{"valor": "...", "percentual": "..."}` **por mês**, gravado como **texto, não float**, para não perder precisão; alinhado por posição com `ContabilApuracao.analise_vertical_meses`. `valores_anterior_reprocessamento` tem o mesmo formato (não existe um valor único aqui). Lista vazia quando o PDF não traz a seção — relatório antigo ou empresa sem essa seção habilitada no Questor; a aba correspondente some nesse caso. @@ -105,7 +126,7 @@ As três têm os mesmos recursos por linha: observação (via `ContabilObservaca ### Apontamentos de auditoria -- **`ContabilAchado`** — nasce automático em `create()`, muda de `status` (`pendente`/`tratado`/`ignorado`) via `ContabilAchadoViewSet`, sempre com `observacao_contador` obrigatória ao sair de pendente. **Um reprocessamento apaga e recria todos os achados do zero** (ver "Reprocessar" abaixo). Dois campos de alvo, mutuamente exclusivos e os dois opcionais: `conta` (FK para `ContabilConta`) e `linha_analise_vertical` (FK para `ContabilLinhaAnaliseVertical`). As duas regras gerais (balanceamento e débito/crédito) não têm alvo nenhum. +- **`ContabilAchado`** — nasce automático em `create()`, muda de `status` (`pendente`/`tratado`/`ignorado`) via `ContabilAchadoViewSet`, sempre com `observacao_contador` obrigatória ao sair de pendente, **exceto** `variacao_atipica_dre`, validada sem texto pela action `validar()` (o texto dela é observação da linha, ver "Variações atípicas no fim da aba Análise Vertical"). **Um reprocessamento apaga e recria todos os achados do zero** (ver "Reprocessar" abaixo). Dois campos de alvo, mutuamente exclusivos e os dois opcionais: `conta` (FK para `ContabilConta`) e `linha_analise_vertical` (FK para `ContabilLinhaAnaliseVertical`). As duas regras gerais (balanceamento e débito/crédito) não têm alvo nenhum. > **"Achado" nunca aparece em texto visível ao usuário** — pedido explícito. Na UI a aba se chama "Observações" e os textos falam em "observação"/"apontamento". `achado`/`ContabilAchado`/`achados_com_observacao` continuam normais como nome de model/variável/classe CSS. Ver `[[feedback_nunca_achado_em_texto_visivel]]` na memória. @@ -254,6 +275,7 @@ Todos os endpoints usam `PermissaoApp("relatorios", "dashboard-contabil")`. Nenh | `/api/contabil-observacoes/{id}/` | PATCH/DELETE | `texto` e/ou `mostrar_ao_cliente`, cada um com sua regra / exclusão restrita ao autor e à revisão | | `/api/contabil-observacoes/{id}/encerrar/`, `/reativar/` | POST | recebem a apuração aberta no corpo, que define a competência de corte | | `/api/contabil-achados/{id}/` | GET/PATCH | tratar/ignorar (exige `observacao_contador` não vazia) | +| `/api/contabil-achados/{id}/validar/` | POST | `{validado: bool}`, **só `variacao_atipica_dre`** (400 nas outras regras): `true` trata sem `observacao_contador`, `false` volta a pendente; bloqueado em apuração concluída | | `/api/contabil-achados/{id}/alternar-oculto/` | POST | esconder a observação do achado no relatório, **independente da tratativa** (um achado pode continuar pendente e ter a observação escondida) | | `/api/contabil-indicadores-definicoes/`, `/{id}/` | GET/POST/PATCH/DELETE | CRUD de indicador (corpo sempre o indicador **inteiro**, inclusive em PATCH) | @@ -344,7 +366,7 @@ Filtros por severidade, por status e por regra, combinados por E lógico. Como n Ordenação sempre por severidade (`PID_DC_SEVERIDADE_ORDEM = {alta:0, media:1, baixa:2}`), preservando a ordem original dentro da mesma severidade (`Array.prototype.sort` é estável). -**Resumo no topo** (`.dc-achados-resumo`): um donut em SVG puro com a contagem por severidade e o total no centro, mais uma grade de 3 cards por **grupo temático** (`PID_DC_GRUPOS`): "Divergências de Saldo" (regras 1-5), "Contas Atípicas" (6-8) e "Variações e Indicadores" (9). Cada card mostra o total do grupo e, por baixo, uma linha por regra (label + contagem); regra sem achado continua listada com `0`, só não clicável. `renderAchadosResumo()` conta sempre sobre `apuracaoAtual.achados` **completo**, nunca sobre a lista filtrada — é uma visão geral estável. +**Resumo no topo** (`.dc-achados-resumo`): um donut em SVG puro com a contagem por severidade e o total no centro, mais uma grade de 3 cards por **grupo temático** (`PID_DC_GRUPOS`): "Divergências de Saldo" (regras 1-5) e "Contas Atípicas" (6-8). A regra 9 (variação atípica) não entra nesta aba, ver "Variações atípicas no fim da aba Análise Vertical". Cada card mostra o total do grupo e, por baixo, uma linha por regra (label + contagem); regra sem achado continua listada com `0`, só não clicável. `renderAchadosResumo()` conta sempre sobre `apuracaoAtual.achados` **completo**, nunca sobre a lista filtrada — é uma visão geral estável. > Sem Chart.js aqui de propósito (essa dependência só existe no relatório estático). O donut usa a técnica clássica de `<circle r="15.9155">` — circunferência ≈ 100, então `stroke-dasharray`/`stroke-dashoffset` já funcionam em unidades de percentual sem `pathLength`. Cada segmento é um `<circle>` próprio, clicável individualmente porque `pointer-events` de SVG só considera pixel realmente pintado pelo `stroke`, sem hit-test manual por ângulo. @@ -364,6 +386,16 @@ Donut, legenda e linhas de regra são clicáveis e filtram a lista abaixo, com ` > **Não existe "ir para a DRE"** porque nenhuma regra hoje referencia `ContabilLinhaDre` diretamente. Se uma regra nova precisar, o padrão se replica sem reprojetar nada: FK `linha_dre` + `ordem_linha_dre` em `AchadoDetectado` + uma entrada em `DC_IR_PARA_CONFIG` com `tab: "dre"`. +### Variações atípicas no fim da aba Análise Vertical + +Pedido explícito do usuário: os apontamentos de `variacao_atipica_dre` aparecem numa seção própria no fim da aba Análise Vertical (`#dc-av-variacoes-list`, `renderVariacoesAnaliseVertical()`, chamada no fim de `renderAnaliseVertical()`), cada um com "Ver na tabela", o botão de observação da linha e um botão "Validar". + +- **Validar trata o apontamento** (`status="tratado"`, com quem/quando) pelo endpoint `validar/`, sem o modal "Revisar". Clicar em "Validado" volta a pendente (`dcValidarVariacaoBtnHtml()`/`dcAlternarValidacaoVariacao()`). +- **Ícone na própria linha da tabela** (pedido do usuário, `dcVariacaoIconeHtml()`/`PID_DC_VARIACAO_ICONE`), antes do botão de validado: dourado enquanto pendente, teal depois de validada. O hover (`.dc-hover-tooltip--largo`) mostra título, mensagem, quem validou e até 3 observações da linha; o clique (`dcIrParaVariacao()`) abre a thread no card da variação, rola até ele e dá um pulso (`dcVariacaoFoco`, 2,4s). Validar redesenha a tabela inteira (`renderAnaliseVertical()`), não só a lista, para o ícone mudar de cor junto. +- **Não aparecem na aba Observações** (pedido explícito do usuário, já estão aqui): `dcAchadosDaAbaObservacoes()` tira `variacao_atipica_dre` da lista, do donut e dos cards de categoria, e o grupo "Variações e Indicadores" saiu de `PID_DC_GRUPOS`. Continuam contando em `total_achados_pendentes` da listagem enquanto não validadas. +- **O texto não é `observacao_contador`**: decisão do usuário, "os textos devem funcionar da mesma maneira que as observações feitas nas contas". O botão de observação do card abre a thread de `ContabilObservacao` da **própria linha** da Análise Vertical (histórico entre competências, "mostrar ao cliente", encerrar/excluir). É a mesma thread da tabela: escrever pelo card aparece na linha e vice-versa, e no relatório a observação sai em "Observações da Análise Vertical" quando marcada para o cliente. +- A thread é montada por `dcObsPainelConteudoHtml(tipo, alvoId, observacoes, concluida, escopo)`: `escopo="variacao"` separa os ids dos campos e a chave em `dcObsPainelAberto` (`variacao`), e o atributo `data-dc-obs-criar` ganha um terceiro pedaço com o escopo. Abrir o painel do card fecha o da tabela e vice-versa, senão a mesma thread existiria duas vezes na tela com os mesmos ids de edição. + ### Aba "Dashboard" Mostra, **antes de gerar o relatório**, os mesmos cards de indicador e a mesma lista de observações que vão para o relatório, com um botão de olho em cada item para escondê-lo do relatório final sem apagar o dado. @@ -490,6 +522,7 @@ Cor por sinal só onde é seguro: `sign` (ROA/ROE/EBIT/EBITDA — positivo verde ## Riscos conhecidos e armadilhas +- **Leiaute Contabit calibrado contra um único arquivo** — ver "Leiaute Contabit": códigos das regras, passos de nível e a premissa do sinal negativo precisam ser conferidos a cada arquivo novo de outro cliente. - **Códigos de classificação fixos** — se um cliente usar plano de contas com numeração diferente, indicadores e a regra de caixa saem errados **silenciosamente**. Revisar contra mais balancetes reais de empresas diferentes antes de confiar cegamente num valor exibido ao cliente. - **Descrições coladas em PDF de fonte atípica** — não têm correção segura; a ferramenta avisa por badge. Valores monetários nunca são afetados. - **Duas cópias mantidas à mão**: os SVGs de ícone (Python + JS), a lógica de árvore/destaque (tela de revisão + relatório) e a montagem do caminho na chave de observação (`chaves.caminhos_linhas()` em Python, `dcAplicaChavesObs()` no JS, mais uma terceira cópia congelada dentro da migração `0078`, que por definição não pode importar código de aplicação). Mudança num lado exige o outro. diff --git a/portal_api/dashboard_contabil/README.md b/portal_api/dashboard_contabil/README.md index 68397bb..1b33e57 100644 --- a/portal_api/dashboard_contabil/README.md +++ b/portal_api/dashboard_contabil/README.md @@ -2,13 +2,13 @@ > Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico (extração do PDF, regras de auditoria, models). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Chamado de "Dashboard Contábil" até uma rodada anterior — renomeado a pedido do usuário (rótulo visível só, nomes técnicos internos continuam `dashboard_contabil`/`Contabil*`). -Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil: 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 automaticamente e roda um conjunto de checagens de auditoria (saldos negativos de caixa, contas transitórias/genéricas com saldo, contas que deveriam estar zeradas, diferença débito/crédito, lucro do balancete diferente da DRE, variações atípicas na DRE) — o contador revisa os achados, marca contas/linhas como validadas, registra observações por conta/linha (thread inline na própria tabela, com um toggle "mostrar ao cliente" que nasce desmarcado), escreve um resumo livre do fechamento (considerações/análises, texto rico com imagem) e conclui a análise. As observações não morrem com a competência: cada uma fica no histórico daquela empresa + conta, reaparecendo na análise do mês seguinte assinada por quem escreveu e com a data, travada pra edição, até o contador encerrá-la para as próximas execuções. Quando o PDF traz a seção "Demonstração Mensal (Análise Vertical)" (histórico de 3 meses, valor+variação percentual por linha), ela também é extraída e ganha sua própria aba de revisão (mesmos recursos de observação/validado/ocultar do Balancete/D.R.E.). Se o arquivo original estava errado/incompleto, o botão "Reprocessar" reanexa um PDF novo pra mesma empresa/competência sem perder nada já registrado — conta/linha sem mudança mantém observação e validação, a que mudou volta pra "não validada" com um alerta, e achado já tratado nunca é apagado ou tem sua justificativa sobrescrita. O botão "Gerar Relatório" gera um relatório HTML autocontido (resumo do fechamento, indicadores financeiros, DRE/Balancete/Análise Vertical agrupados, observações do contador) com a marca do escritório, pronto pra enviar ao administrador da empresa — a aba "Resumo" desse relatório também pode ser exportada avulsa em PDF (resumo do fechamento + indicadores + observações, sem o resto do relatório). +Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil: o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório hoje enviado ao cliente, gerado pelo Questor ou pelo Contabit; o modelo é detectado sozinho, e no Contabit o código da empresa vem do nome do arquivo, ex. "1512 - Balancete 082026.pdf"), a ferramenta extrai as contas automaticamente e roda um conjunto de checagens de auditoria (saldos negativos de caixa, contas transitórias/genéricas com saldo, contas que deveriam estar zeradas, diferença débito/crédito, lucro do balancete diferente da DRE, variações atípicas na DRE) — o contador revisa os achados, marca contas/linhas como validadas, registra observações por conta/linha (thread inline na própria tabela, com um toggle "mostrar ao cliente" que nasce desmarcado), escreve um resumo livre do fechamento (considerações/análises, texto rico com imagem) e conclui a análise. As observações não morrem com a competência: cada uma fica no histórico daquela empresa + conta, reaparecendo na análise do mês seguinte assinada por quem escreveu e com a data, travada pra edição, até o contador encerrá-la para as próximas execuções. Quando o PDF traz a seção "Demonstração Mensal (Análise Vertical)" (histórico de 3 meses, valor+variação percentual por linha), ela também é extraída e ganha sua própria aba de revisão (mesmos recursos de observação/validado/ocultar do Balancete/D.R.E.). Se o arquivo original estava errado/incompleto, o botão "Reprocessar" reanexa um PDF novo pra mesma empresa/competência sem perder nada já registrado — conta/linha sem mudança mantém observação e validação, a que mudou volta pra "não validada" com um alerta, e achado já tratado nunca é apagado ou tem sua justificativa sobrescrita. O botão "Gerar Relatório" gera um relatório HTML autocontido (resumo do fechamento, indicadores financeiros, DRE/Balancete/Análise Vertical agrupados, observações do contador) com a marca do escritório, pronto pra enviar ao administrador da empresa — a aba "Resumo" desse relatório também pode ser exportada avulsa em PDF (resumo do fechamento + indicadores + observações, sem o resto do relatório). -**Ainda fora de escopo**: exportação em XLSX; consolidação entre várias empresas/competências ao mesmo tempo (é sempre uma apuração por vez). +**Ainda fora de escopo**: indicadores para empresas do Contabit (a seção fica oculta nesse modelo); consolidação entre várias empresas/competências ao mesmo tempo (é sempre uma apuração por vez). ## Onde mexer -- `portal_api/dashboard_contabil/` — `parser.py` (extração do PDF via `pdfplumber`), `regras.py` (motor de regras de auditoria), `indicadores.py` (indicadores financeiros do relatório), `pipeline.py` (orquestração), `modelos.py` (dataclasses), `chaves.py` (chave natural de conta/linha), `exportacao.py` (Balancete/DRE em XLSX), `resumo_pdf.py` (Resumo do Fechamento + Indicadores + Observações em PDF, avulso). +- `portal_api/dashboard_contabil/` — `parser.py` (extração do PDF Questor via `pdfplumber` e detecção do modelo), `parser_contabit.py` (extração do PDF Contabit), `regras.py` (motor de regras de auditoria), `indicadores.py` (indicadores financeiros do relatório), `pipeline.py` (orquestração), `modelos.py` (dataclasses), `chaves.py` (chave natural de conta/linha), `exportacao.py` (Balancete/DRE em XLSX), `resumo_pdf.py` (Resumo do Fechamento + Indicadores + Observações em PDF, avulso). - `ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical`/`ContabilAchado`/`ContabilObservacao`/`ContabilObservacaoEdicao` (`portal_api/models.py`). - `dashboard-contabil.html` / `static/js/dashboard-contabil.js` / `static/css/dashboard-contabil.css`. - `dashboard-contabil-relatorio.html` (relatório em si) / `portal_api/templatetags/contabil_extras.py` (filtros de formatação). diff --git a/portal_api/dashboard_contabil/conversao.py b/portal_api/dashboard_contabil/conversao.py new file mode 100644 index 0000000..2875999 --- /dev/null +++ b/portal_api/dashboard_contabil/conversao.py @@ -0,0 +1,56 @@ +"""Conversões e erros compartilhados pelos parsers de PDF (`parser.py`, leiaute +Questor, e `parser_contabit.py`, leiaute Contabit). Vivem aqui, e não em +`parser.py`, só pra evitar import circular: `parser.py` despacha para +`parser_contabit.py`, que precisa das mesmas conversões. `parser.py` +reexporta `ExtracaoInvalidaError`, então quem já importava de lá continua +funcionando.""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal, InvalidOperation + + +class ExtracaoInvalidaError(Exception): + """PDF não tem o formato esperado (cabeçalho/tabela do Balancete/DRE não + encontrados) — a view converte isso numa resposta 400 genérica, mesmo + padrão de `planos_saude`/`indicadores`.""" + + +class CodigoEmpresaAusenteError(ExtracaoInvalidaError): + """O leiaute Contabit não traz o código da empresa no PDF, então ele vem + do nome do arquivo ("1512 - Balancete 082026.pdf"). Subclasse própria pra + a view mostrar uma mensagem específica em vez da genérica de formato + inválido: o PDF em si está certo, só o nome do arquivo não segue o + padrão.""" + + +def para_decimal(texto: str) -> Decimal: + texto = texto.strip() + # Algumas empresas têm linhas de DRE negativas no PDF com só o parêntese + # de abertura ("(1.114.989,29", sem o "()" de fechamento) — confirmado + # direto nos caracteres brutos do PDF (não é um artefato de + # _reconstroi_linhas), então o "(" sozinho já basta pra marcar negativo. + negativo = texto.startswith("(") + if negativo: + texto = texto[1:] + if texto.endswith(")"): + texto = texto[:-1] + texto = texto.replace(".", "").replace(",", ".") + try: + valor = Decimal(texto) + except InvalidOperation as exc: + raise ExtracaoInvalidaError(f"Valor monetário inválido: {texto!r}") from exc + return -valor if negativo else valor + + +def para_percentual(texto: str) -> Decimal: + """Mesma conversão de `para_decimal`, só removendo o "%" antes — o sinal + negativo continua expresso só pelo parêntese (ex. "(7,33%)") no Questor, + ou pelo "-" no Contabit, igual ao valor monetário.""" + return para_decimal(texto.replace("%", "")) + + +def para_data(texto: str) -> date: + dia, mes, ano = texto.split("/") + return date(int(ano), int(mes), int(dia)) diff --git a/portal_api/dashboard_contabil/modelos.py b/portal_api/dashboard_contabil/modelos.py index 4ddc025..6f946c5 100644 --- a/portal_api/dashboard_contabil/modelos.py +++ b/portal_api/dashboard_contabil/modelos.py @@ -9,6 +9,13 @@ from dataclasses import dataclass, field from datetime import date from decimal import Decimal +# Sistema contábil que gerou o PDF — detectado pelo próprio parser +# (`parser.extrai_balancete_dre()`), nunca informado pelo usuário. Muda o +# plano de contas (códigos fixos das regras), a convenção de sinal dos +# saldos e se a apuração tem indicadores (ver CLAUDE.md do pacote). +LEIAUTE_QUESTOR = "questor" +LEIAUTE_CONTABIT = "contabit" + @dataclass class CabecalhoExtraido: @@ -21,7 +28,8 @@ class CabecalhoExtraido: @dataclass class LinhaBalanceteExtraida: - conta_numero: int + # `None` só no Contabit, onde a conta sintética não tem número próprio. + conta_numero: int | None codigo: str descricao: str tipo: str # "S" (sintética) ou "A" (analítica) @@ -76,6 +84,7 @@ class ResultadoExtracao: # `ContabilApuracao.fonte_pdf_atipica`, só pra alertar o contador — não # bloqueia nem tenta corrigir nada automaticamente. fonte_pdf_atipica: bool = False + leiaute: str = LEIAUTE_QUESTOR @dataclass diff --git a/portal_api/dashboard_contabil/parser.py b/portal_api/dashboard_contabil/parser.py index d88be55..febbd1f 100644 --- a/portal_api/dashboard_contabil/parser.py +++ b/portal_api/dashboard_contabil/parser.py @@ -1,6 +1,6 @@ -"""Extração do PDF de Balancete + DRE (leiaute Questor, único leiaute-fonte — -ao contrário de `portal_api.planos_saude`, que precisa de um parser por -operadora, aqui o relatório é sempre gerado pelo mesmo sistema contábil). +"""Extração do PDF de Balancete + DRE. Dois leiautes: Questor (este módulo) e +Contabit (`parser_contabit.py`) — `extrai_balancete_dre()` detecta qual é +pela primeira página e despacha; o resto deste docstring trata do Questor. O texto deste relatório é desenhado caractere a caractere (cada letra/número tem sua própria posição X, sem depender de kerning do PDF) e o próprio PDF @@ -19,12 +19,15 @@ from __future__ import annotations import re import unicodedata -from datetime import date -from decimal import Decimal, InvalidOperation from typing import IO import pdfplumber +from . import parser_contabit +from .conversao import CodigoEmpresaAusenteError, ExtracaoInvalidaError # noqa: F401 — reexportados +from .conversao import para_data as _para_data +from .conversao import para_decimal as _para_decimal +from .conversao import para_percentual as _para_percentual from .modelos import ( CabecalhoExtraido, LinhaAnaliseVerticalExtraida, @@ -95,43 +98,6 @@ _RE_MES_ANALISE_VERTICAL = re.compile(r"([A-Za-z]{3})\s*-\s*(\d{4})") LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRAS = "DESPESAS/RECEITAS FINANCEIRAS" -class ExtracaoInvalidaError(Exception): - """PDF não tem o formato esperado (cabeçalho/tabela do Balancete/DRE não - encontrados) — a view converte isso numa resposta 400 genérica, mesmo - padrão de `planos_saude`/`indicadores`.""" - - -def _para_decimal(texto: str) -> Decimal: - texto = texto.strip() - # Algumas empresas têm linhas de DRE negativas no PDF com só o parêntese - # de abertura ("(1.114.989,29", sem o "()" de fechamento) — confirmado - # direto nos caracteres brutos do PDF (não é um artefato de - # _reconstroi_linhas), então o "(" sozinho já basta pra marcar negativo. - negativo = texto.startswith("(") - if negativo: - texto = texto[1:] - if texto.endswith(")"): - texto = texto[:-1] - texto = texto.replace(".", "").replace(",", ".") - try: - valor = Decimal(texto) - except InvalidOperation as exc: - raise ExtracaoInvalidaError(f"Valor monetário inválido: {texto!r}") from exc - return -valor if negativo else valor - - -def _para_percentual(texto: str) -> Decimal: - """Mesma conversão de `_para_decimal`, só removendo o "%" antes — o sinal - negativo continua expresso só pelo parêntese (ex. "(7,33%)"), igual ao - valor monetário.""" - return _para_decimal(texto.replace("%", "")) - - -def _para_data(texto: str) -> date: - dia, mes, ano = texto.split("/") - return date(int(ano), int(mes), int(dia)) - - def _reconstroi_linhas(pagina) -> list[dict]: caracteres = [c for c in pagina.chars if c["text"] != " "] linhas: dict[float, list] = {} @@ -192,9 +158,14 @@ def _extrai_cabecalho(primeiras_linhas: list[str]) -> CabecalhoExtraido: ) -def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao: - """Lê o PDF combinado de Balancete + DRE (modelo Questor) e devolve tudo - já estruturado. `origem` aceita tanto um caminho em disco quanto um +def extrai_balancete_dre(origem: str | IO[bytes], nome_arquivo: str | None = None) -> ResultadoExtracao: + """Lê o PDF combinado de Balancete + DRE e devolve tudo já estruturado. + Um PDF do Contabit (detectado pela primeira página) vai para + `parser_contabit.extrai()`, que usa `nome_arquivo` pra obter o código da + empresa (o PDF não traz); o Questor segue abaixo, lendo o código do + próprio PDF e ignorando `nome_arquivo`. + + `origem` aceita tanto um caminho em disco quanto um arquivo já aberto em memória (`io.BytesIO`) — a view chama isto direto sobre o upload, antes de saber `codigo_empresa`/`competencia` (extraídos daqui), então ainda não há um caminho em disco definitivo nesse momento. @@ -227,6 +198,8 @@ def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao: with pdfplumber.open(origem) as pdf: if not pdf.pages: raise ExtracaoInvalidaError("O PDF não tem páginas.") + if parser_contabit.eh_leiaute_contabit(pdf.pages[0]): + return parser_contabit.extrai(pdf, nome_arquivo) primeiras_linhas_pagina1 = [ linha["texto"] for linha in _reconstroi_linhas(pdf.pages[0]) if linha["texto"].strip() diff --git a/portal_api/dashboard_contabil/parser_contabit.py b/portal_api/dashboard_contabil/parser_contabit.py new file mode 100644 index 0000000..0f8b535 --- /dev/null +++ b/portal_api/dashboard_contabil/parser_contabit.py @@ -0,0 +1,342 @@ +"""Extração do PDF de Balancete + DRE gerado pelo Contabit (o segundo sistema +contábil usado pelo escritório). `parser.extrai_balancete_dre()` detecta o +leiaute pela primeira página (`eh_leiaute_contabit()`) e despacha pra cá; +o resultado sai no mesmo `ResultadoExtracao` do Questor, com +`leiaute="contabit"`. + +Calibrado contra `1512 - Balancete 082026.pdf` (arquivo de referência, fora +do repositório, em `Projetos\\Balancetes\\Contabit`). Diferenças de leiaute +que moldam este módulo: + +- **Sem código da empresa no PDF.** O cabeçalho só tem nome e CNPJ; o código + vem do prefixo numérico do nome do arquivo (decisão explícita do usuário). +- **Balancete: classificação, descrição, nº da conta, 4 valores.** Não existe + flag S/A: conta sintética é a que não tem nº de conta. +- **Descrição longa "vaza" por cima das colunas seguintes.** O PDF corta a + descrição na tela (clip), mas o texto inteiro continua no fluxo e seus + caracteres ficam na mesma faixa horizontal do nº da conta e até do Saldo + Anterior. Ordenar por `x0` intercala os dois ("GRAFICOS2 2L9T3D5A" = + "GRAFICOS LTDA" + "22935"). Por isso o Balancete é lido na **ordem do + fluxo do PDF** (descrição, depois os valores, depois o nº da conta, cada um + um trecho contínuo), e só a posição dos 4 valores entre si usa `x0`. +- **Saldos pela natureza da conta.** Passivo e contas redutoras aparecem + positivos (não negativos como no Questor); o grupo pai já faz a subtração. +- **DRE com negativo em "-"** (não entre parênteses) e, nas linhas em negrito, + o valor desenhado 1pt abaixo do texto — por isso as linhas são agrupadas + com tolerância vertical, não por `round(top)`. +- **Demonstração mensal** com meses por extenso ("JUNHO/2026"), AV% sem "%", + uma coluna TOTAL a mais (soma dos meses mostrados, descartada) e linhas + espaçadoras só com zeros, sem descrição (ignoradas).""" + +from __future__ import annotations + +import os +import re + +from .conversao import CodigoEmpresaAusenteError, ExtracaoInvalidaError, para_data, para_decimal, para_percentual +from .modelos import ( + LEIAUTE_CONTABIT, + CabecalhoExtraido, + LinhaAnaliseVerticalExtraida, + LinhaBalanceteExtraida, + LinhaDreExtraida, + ResultadoExtracao, + ValorMensalAnaliseVertical, +) + +# Tolerância vertical (pt) pra considerar dois caracteres da mesma linha +# visual. Cobre o valor da linha em negrito da DRE (1pt abaixo do texto) e a +# linha de AV% da demonstração mensal (1pt abaixo do valor); duas linhas +# reais de tabela ficam sempre ~11pt uma da outra. +_TOLERANCIA_LINHA = 1.6 + +# Vão horizontal (pt) acima do qual dois caracteres consecutivos (ordenados +# por x) viram campos diferentes. Diferente do Questor, aqui o próprio PDF +# desenha os espaços entre palavras como caractere, então o vão só precisa +# separar colunas. +_GAP_CAMPO = 1.0 + +# Leitura em ordem de fluxo (Balancete): um trecho novo começa quando o +# próximo caractere volta pra trás (além do que o kerning explica, ex. "LT" +# em "LTDA") ou pula pra frente além de um espaço normal. +_RECUO_MAXIMO_KERNING = 2.0 +_SALTO_MAXIMO_TRECHO = 4.0 + +# Recuo (pt) de cada nível da árvore. DRE: 67.7 → 75.2 → 82.6 → 90.1; +# demonstração mensal: 30.7 → 36.7 → 42.6 → 48.6. +_PASSO_NIVEL_DRE = 7.45 +_PASSO_NIVEL_MENSAL = 5.95 + +_RE_MONETARIO_EXATO = re.compile(r"^-?[\d.]+,\d{2}$") +_RE_MONETARIO = re.compile(r"-?[\d.]+,\d{2}") +_RE_CLASSIFICACAO = re.compile(r"^(?P<codigo>\d+(?:\.\d+)*)\s+(?P<descricao>.+)$") +_RE_NUMERO_CONTA = re.compile(r"^\d+$") +_RE_CNPJ = re.compile(r"CNPJ:\s*([\d./-]+)") +_RE_PERIODO = re.compile(r"PER[IÍ]ODO DE\s*(\d{2}/\d{2}/\d{4})\s*[AÀ]\s*(\d{2}/\d{2}/\d{4})", re.IGNORECASE) +_RE_MES_MENSAL = re.compile(r"([A-ZÇ]+)/(\d{4})") +_RE_CODIGO_NO_NOME_ARQUIVO = re.compile(r"^\s*(\d+)") + +_MESES_ABREVIADOS = { + "JANEIRO": "jan", + "FEVEREIRO": "fev", + "MARCO": "mar", + "MARÇO": "mar", + "ABRIL": "abr", + "MAIO": "mai", + "JUNHO": "jun", + "JULHO": "jul", + "AGOSTO": "ago", + "SETEMBRO": "set", + "OUTUBRO": "out", + "NOVEMBRO": "nov", + "DEZEMBRO": "dez", +} + +_SECAO_BALANCETE = "balancete" +_SECAO_DRE = "dre" +_SECAO_MENSAL = "mensal" + + +def _agrupa_linhas(pagina) -> list[list[dict]]: + """Caracteres da página agrupados por linha visual, **preservando a ordem + do fluxo do PDF** dentro de cada linha (é o que `pagina.chars` já + entrega). Linhas em ordem de `top`.""" + grupos: list[tuple[float, list[dict]]] = [] + for c in sorted(pagina.chars, key=lambda c: c["top"]): + if grupos and c["top"] - grupos[-1][0] <= _TOLERANCIA_LINHA: + grupos[-1][1].append(c) + else: + grupos.append((c["top"], [c])) + ordem_fluxo = {id(c): i for i, c in enumerate(pagina.chars)} + return [sorted(chars, key=lambda c: ordem_fluxo[id(c)]) for _, chars in grupos] + + +def _texto_por_posicao(chars: list[dict]) -> str: + """Texto da linha lido da esquerda pra direita, espaços colapsados.""" + texto = "" + anterior_x1 = None + for c in sorted(chars, key=lambda c: c["x0"]): + if anterior_x1 is not None and c["x0"] - anterior_x1 > _GAP_CAMPO: + texto += " " + texto += c["text"] + anterior_x1 = c["x1"] + return " ".join(texto.split()) + + +def _trechos_por_fluxo(chars: list[dict]) -> list[tuple[str, float]]: + """Trechos contínuos da linha na ordem do fluxo do PDF, cada um com o + `x0` do primeiro caractere não-espaço (ver docstring do módulo).""" + trechos: list[tuple[str, float]] = [] + atual = "" + x0_atual: float | None = None + anterior = None + for c in chars: + if anterior is not None and ( + c["x0"] < anterior["x1"] - _RECUO_MAXIMO_KERNING or c["x0"] - anterior["x1"] > _SALTO_MAXIMO_TRECHO + ): + if atual.strip(): + trechos.append((" ".join(atual.split()), x0_atual)) + atual, x0_atual = "", None + atual += c["text"] + if x0_atual is None and c["text"].strip(): + x0_atual = c["x0"] + anterior = c + if atual.strip(): + trechos.append((" ".join(atual.split()), x0_atual)) + return trechos + + +def _x0_texto(chars: list[dict]) -> float: + return min(c["x0"] for c in chars if c["text"].strip()) + + +def _negrito(chars: list[dict]) -> bool: + return any("bold" in c["fontname"].lower() for c in chars if c["text"].strip()) + + +def eh_leiaute_contabit(primeira_pagina) -> bool: + """O cabeçalho do Contabit tem "Classificação/Conta" (coluna única) e o + período no formato "PERÍODO DE ... À ..."; o Questor usa "Período:" e + colunas separadas de conta/classificação.""" + textos = [_texto_por_posicao(chars) for chars in _agrupa_linhas(primeira_pagina)[:12]] + return any("Classificação/Conta" in t for t in textos) and any(_RE_PERIODO.search(t) for t in textos) + + +def codigo_empresa_do_nome_arquivo(nome_arquivo: str | None) -> str: + """Prefixo numérico do nome do arquivo ("1512 - Balancete 082026.pdf" → + "1512"). Sem prefixo numérico, a análise não tem como ser identificada + (é a chave natural junto da competência) e o upload é recusado.""" + base = os.path.basename(nome_arquivo or "") + match = _RE_CODIGO_NO_NOME_ARQUIVO.match(base) + if not match: + raise CodigoEmpresaAusenteError( + "No modelo Contabit o código da empresa vem do nome do arquivo, que precisa começar " + 'pelo código (ex.: "1512 - Balancete 082026.pdf").' + ) + return match.group(1) + + +def _extrai_cabecalho(primeira_pagina, nome_arquivo: str | None) -> CabecalhoExtraido: + textos = [_texto_por_posicao(chars) for chars in _agrupa_linhas(primeira_pagina)[:12]] + texto_completo = " ".join(textos) + match_cnpj = _RE_CNPJ.search(texto_completo) + match_periodo = _RE_PERIODO.search(texto_completo) + if not match_cnpj or not match_periodo: + raise ExtracaoInvalidaError("Não foi possível localizar CNPJ/período no cabeçalho do PDF.") + + # O nome da empresa é a linha logo acima do "CNPJ:". + indice_cnpj = next(i for i, t in enumerate(textos) if t.startswith("CNPJ:")) + nome_empresa = textos[indice_cnpj - 1].strip() if indice_cnpj > 0 else "" + if not nome_empresa or _RE_PERIODO.search(nome_empresa): + raise ExtracaoInvalidaError("Não foi possível localizar o nome da empresa no cabeçalho do PDF.") + + return CabecalhoExtraido( + codigo_empresa=codigo_empresa_do_nome_arquivo(nome_arquivo), + nome_empresa=nome_empresa, + cnpj=match_cnpj.group(1), + periodo_inicio=para_data(match_periodo.group(1)), + periodo_fim=para_data(match_periodo.group(2)), + ) + + +def _secao_da_pagina(pagina) -> str | None: + textos = [_texto_por_posicao(chars) for chars in _agrupa_linhas(pagina)[:8]] + cabecalho = " ".join(textos).upper() + if "CLASSIFICAÇÃO/CONTA" in cabecalho: + return _SECAO_BALANCETE + if "RESULTADO ACUMULADO" in cabecalho: + return _SECAO_DRE + if "MENSAL" in cabecalho and "DEMONSTRAÇÃO DO RESULTADO" in cabecalho: + return _SECAO_MENSAL + return None + + +def _linha_balancete(chars: list[dict]) -> LinhaBalanceteExtraida | None: + trechos = _trechos_por_fluxo(chars) + if not trechos: + return None + valores = [(texto, x0) for texto, x0 in trechos if _RE_MONETARIO_EXATO.match(texto)] + numeros_conta = [texto for texto, _ in trechos if _RE_NUMERO_CONTA.match(texto)] + descricao_trechos = [ + texto for texto, _ in trechos if not _RE_MONETARIO_EXATO.match(texto) and not _RE_NUMERO_CONTA.match(texto) + ] + match = _RE_CLASSIFICACAO.match(" ".join(descricao_trechos)) + if not match: + return None + if len(valores) != 4 or len(numeros_conta) > 1: + raise ExtracaoInvalidaError(f"Linha de balancete com colunas inesperadas: {match.group('codigo')}.") + saldo_anterior, debito, credito, saldo_atual = (texto for texto, _ in sorted(valores, key=lambda v: v[1])) + return LinhaBalanceteExtraida( + conta_numero=int(numeros_conta[0]) if numeros_conta else None, + codigo=match.group("codigo"), + descricao=match.group("descricao").strip(), + tipo="A" if numeros_conta else "S", + saldo_anterior=para_decimal(saldo_anterior), + debito=para_decimal(debito), + credito=para_decimal(credito), + saldo_atual=para_decimal(saldo_atual), + ) + + +def _separa_descricao_valores(chars: list[dict]) -> tuple[str, list[str]]: + texto = _texto_por_posicao(chars) + primeiro = _RE_MONETARIO.search(texto) + if not primeiro: + return texto, [] + return texto[: primeiro.start()].strip(), _RE_MONETARIO.findall(texto[primeiro.start():]) + + +def _meses_do_cabecalho(texto: str) -> list[str]: + meses = [] + for mes, ano in _RE_MES_MENSAL.findall(texto.upper()): + abreviado = _MESES_ABREVIADOS.get(mes) + if abreviado is None: + raise ExtracaoInvalidaError(f"Mês não reconhecido no cabeçalho da demonstração mensal: {mes!r}.") + meses.append(f"{abreviado}/{ano}") + return meses + + +def extrai(pdf, nome_arquivo: str | None) -> ResultadoExtracao: + """Lê um PDF Contabit já aberto (`pdfplumber.PDF`) — ver + `parser.extrai_balancete_dre()`, que abre o arquivo e despacha pra cá.""" + cabecalho = _extrai_cabecalho(pdf.pages[0], nome_arquivo) + contas: list[LinhaBalanceteExtraida] = [] + linhas_dre: list[LinhaDreExtraida] = [] + linhas_mensal: list[LinhaAnaliseVerticalExtraida] = [] + meses: list[str] | None = None + base_x0_dre: float | None = None + base_x0_mensal: float | None = None + + for pagina in pdf.pages: + secao = _secao_da_pagina(pagina) + if secao is None: + continue + for chars in _agrupa_linhas(pagina): + if not any(c["text"].strip() for c in chars): + continue + if secao == _SECAO_BALANCETE: + conta = _linha_balancete(chars) + if conta is not None: + contas.append(conta) + continue + + if secao == _SECAO_MENSAL and meses is None: + candidatos = _meses_do_cabecalho(_texto_por_posicao(chars)) + if candidatos: + meses = candidatos + continue + + descricao, valores = _separa_descricao_valores(chars) + # Linha sem descrição (espaçadora "0,00 0,00", valor solto) ou + # sem valor (título, cabeçalho, assinatura) não é dado. + if not descricao or not valores: + continue + x0 = _x0_texto(chars) + if secao == _SECAO_DRE: + if base_x0_dre is None: + base_x0_dre = x0 + linhas_dre.append( + LinhaDreExtraida( + ordem=len(linhas_dre) + 1, + descricao=descricao, + nivel=max(0, round((x0 - base_x0_dre) / _PASSO_NIVEL_DRE)), + valor=para_decimal(valores[-1]), + totalizador=_negrito(chars), + ) + ) + else: + if meses is None: + raise ExtracaoInvalidaError("Cabeçalho de meses da demonstração mensal não encontrado.") + # Pares valor + AV% por mês, mais o par da coluna TOTAL no fim. + if len(valores) != 2 * (len(meses) + 1): + raise ExtracaoInvalidaError(f"Linha da demonstração mensal com colunas inesperadas: {descricao!r}.") + if base_x0_mensal is None: + base_x0_mensal = x0 + linhas_mensal.append( + LinhaAnaliseVerticalExtraida( + ordem=len(linhas_mensal) + 1, + descricao=descricao, + nivel=max(0, round((x0 - base_x0_mensal) / _PASSO_NIVEL_MENSAL)), + totalizador=_negrito(chars), + valores=[ + ValorMensalAnaliseVertical( + valor=para_decimal(valores[2 * i]), percentual=para_percentual(valores[2 * i + 1]) + ) + for i in range(len(meses)) + ], + ) + ) + + if not contas: + raise ExtracaoInvalidaError("Nenhuma linha de balancete encontrada no PDF.") + if not linhas_dre: + raise ExtracaoInvalidaError("Nenhuma linha de DRE encontrada no PDF.") + + return ResultadoExtracao( + cabecalho=cabecalho, + contas=contas, + linhas_dre=linhas_dre, + meses_analise_vertical=meses or [], + linhas_analise_vertical=linhas_mensal, + leiaute=LEIAUTE_CONTABIT, + ) diff --git a/portal_api/dashboard_contabil/pipeline.py b/portal_api/dashboard_contabil/pipeline.py index e0698ab..28aca84 100644 --- a/portal_api/dashboard_contabil/pipeline.py +++ b/portal_api/dashboard_contabil/pipeline.py @@ -19,9 +19,13 @@ from .regras import gera_achados def processa_apuracao( - origem: str | IO[bytes], busca_historico: Callable[[CabecalhoExtraido], list[SnapshotHistorico]] + origem: str | IO[bytes], + busca_historico: Callable[[CabecalhoExtraido], list[SnapshotHistorico]], + nome_arquivo: str | None = None, ) -> ResultadoProcessamento: - extracao = extrai_balancete_dre(origem) + """`nome_arquivo` só é usado no leiaute Contabit, que tira dele o código + da empresa (ver `parser_contabit.codigo_empresa_do_nome_arquivo`).""" + extracao = extrai_balancete_dre(origem, nome_arquivo) historico = busca_historico(extracao.cabecalho) achados = gera_achados(extracao, historico) return ResultadoProcessamento(extracao=extracao, achados=achados) diff --git a/portal_api/dashboard_contabil/regras.py b/portal_api/dashboard_contabil/regras.py index 1fd88c5..00a6313 100644 --- a/portal_api/dashboard_contabil/regras.py +++ b/portal_api/dashboard_contabil/regras.py @@ -11,7 +11,13 @@ from __future__ import annotations import re from decimal import Decimal -from .modelos import AchadoDetectado, LinhaBalanceteExtraida, ResultadoExtracao, SnapshotHistorico +from .modelos import ( + LEIAUTE_CONTABIT, + AchadoDetectado, + LinhaBalanceteExtraida, + ResultadoExtracao, + SnapshotHistorico, +) # Exclui "NUMERÁRIOS EM TRANSITO"/"... EM TRANSITO" (dinheiro em trânsito, # conceito diferente de conta transitória/de compensação) do match de @@ -46,6 +52,15 @@ TRECHOS_CONTA_DEVERIA_ZERAR = ["ADIANTAMENTOS DE SALÁRIOS", "ADIANTAMENTO DE SA # saldo desta linha agregadora, não de uma das duas filhas específicas. CODIGO_LUCRO_PREJUIZO_EXERCICIO = "2.04.13.002" +# Equivalentes no plano de contas do Contabit, calibrados contra `1512 - +# Balancete 082026.pdf` (único arquivo Contabit de referência até aqui): +# "2.40.40.20 LUCROS / PREJUIZOS DO EXERCÍCIO" e "1.10.10.01 BENS NUMERÁRIOS" +# (o grupo que contém "Caixa"). Mesmo risco dos códigos do Questor: se outro +# cliente Contabit usar numeração diferente, a regra deixa de disparar. +CODIGO_LUCRO_PREJUIZO_EXERCICIO_CONTABIT = "2.40.40.20" +PREFIXO_CAIXA_QUESTOR = "1.01.01.001" +PREFIXO_CAIXA_CONTABIT = "1.10.10.01" + VARIACAO_LIMIAR_PERCENTUAL = Decimal("0.65") # 65% VARIACAO_VALOR_MINIMO = Decimal("1000") # ignora variações abaixo disso, mesmo que %-mente grandes # Piso em pontos percentuais pra regra_variacao_atipica_dre (Análise Vertical) @@ -55,6 +70,11 @@ VARIACAO_VALOR_MINIMO = Decimal("1000") # ignora variações abaixo disso, mesm # relativa, mas irrelevante em termos de composição da receita). VARIACAO_AV_PONTOS_PERCENTUAIS_MINIMO = Decimal("1") +# Nome pedido pelo usuário pro apontamento da regra de variação: deixa claro +# que ele vem da Análise Vertical, não da DRE acumulada. A migração `0081` +# aplicou o mesmo título aos apontamentos já gravados. +TITULO_VARIACAO_ATIPICA_DRE = "Variação atípica na DRE - Análise Vertical" + def _moeda(valor: Decimal) -> str: """Formata no padrão brasileiro (ponto de milhar, vírgula decimal) — @@ -74,7 +94,10 @@ def regra_balanceamento_ativo_passivo(atual: ResultadoExtracao, historico: list[ 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 + # Questor: o Passivo já vem negativo no relatório, então fechar é somar + # a zero. Contabit: os dois vêm positivos (saldo pela natureza). + saldo_passivo = passivo.saldo_atual if atual.leiaute == LEIAUTE_CONTABIT else -passivo.saldo_atual + diferenca = ativo.saldo_atual - saldo_passivo if diferenca == 0: return [] return [ @@ -84,7 +107,7 @@ def regra_balanceamento_ativo_passivo(atual: ResultadoExtracao, historico: list[ titulo="Ativo não bate com Passivo", mensagem=( f"Saldo do Ativo ({_moeda(ativo.saldo_atual)}) não coincide com o do Passivo " - f"({_moeda(-passivo.saldo_atual)}) — diferença de {_moeda(abs(diferenca))}." + f"({_moeda(saldo_passivo)}) — diferença de {_moeda(abs(diferenca))}." ), valor_referencia=diferenca, ) @@ -114,8 +137,9 @@ def regra_debito_credito_divergente(atual: ResultadoExtracao, historico: list[Sn def regra_saldo_negativo_caixa(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: achados = [] + prefixo = PREFIXO_CAIXA_CONTABIT if atual.leiaute == LEIAUTE_CONTABIT else PREFIXO_CAIXA_QUESTOR for conta in atual.contas: - if conta.codigo.startswith("1.01.01.001") and conta.saldo_atual < 0: + if conta.codigo.startswith(prefixo) and conta.saldo_atual < 0: achados.append( AchadoDetectado( regra="saldo_negativo_caixa", @@ -204,7 +228,42 @@ def _indices_descendentes_de_conta_redutora(contas: list[LinhaBalanceteExtraida] return descendentes +def _saldo_sinal_invertido_contabit(atual: ResultadoExtracao) -> list[AchadoDetectado]: + """No Contabit o saldo já sai pela natureza da própria conta (Passivo e + redutoras positivos), então saldo contrário à natureza é simplesmente + saldo negativo, em qualquer grupo. A natureza (devedora/credora) só + importa pro texto: grupo 1 é devedor, grupo 2 credor, invertido quando a + conta é redutora ou descende de uma (mesma pilha de níveis do Questor). + + Premissa ainda não confirmada com arquivo real: o Contabit imprimir o + saldo contrário com "-". Se imprimir de outro jeito, a regra só deixa de + disparar (sem falso positivo).""" + achados = [] + 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 not conta.codigo.startswith(("1.", "2.")): + continue + eh_ativo = conta.codigo.startswith("1.") + invertida = _eh_conta_redutora(conta) or i in descendentes_de_redutora + natureza_devedora = eh_ativo != invertida + grupo = "Ativo" if eh_ativo else "Passivo" + saldo = "credor" if natureza_devedora else "devedor" + achados.append( + AchadoDetectado( + regra="saldo_sinal_invertido", + severidade=SEVERIDADE_MEDIA, + titulo=f"Conta do {grupo} com saldo {saldo}", + mensagem=f'Conta do {grupo} "{conta.descricao}" ({conta.codigo}) está com saldo {saldo} de {_moeda(-conta.saldo_atual)}.', + codigo_conta=conta.codigo, + valor_referencia=conta.saldo_atual, + ) + ) + return achados + + def regra_saldo_sinal_invertido(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: + if atual.leiaute == LEIAUTE_CONTABIT: + return _saldo_sinal_invertido_contabit(atual) achados = [] descendentes_de_redutora = _indices_descendentes_de_conta_redutora(atual.contas) for i, conta in enumerate(atual.contas): @@ -238,13 +297,16 @@ def regra_saldo_sinal_invertido(atual: ResultadoExtracao, historico: list[Snapsh def regra_lucro_balancete_diverge_dre(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]: """O resultado do exercício precisa ser o mesmo número nas duas demonstrações — só que, por convenção, o Passivo/PL vem com o sinal - invertido no relatório (ver `regra_balanceamento_ativo_passivo`), então - o saldo da linha do Balancete precisa ser negado antes de comparar com a - última linha da DRE.""" - conta_lucro = next((c for c in atual.contas if c.codigo == CODIGO_LUCRO_PREJUIZO_EXERCICIO), None) + invertido no relatório do Questor (ver `regra_balanceamento_ativo_passivo`), + então o saldo da linha do Balancete precisa ser negado antes de comparar + com a última linha da DRE. No Contabit o saldo já sai pela natureza + (lucro positivo), sem negar.""" + contabit = atual.leiaute == LEIAUTE_CONTABIT + codigo_lucro = CODIGO_LUCRO_PREJUIZO_EXERCICIO_CONTABIT if contabit else CODIGO_LUCRO_PREJUIZO_EXERCICIO + conta_lucro = next((c for c in atual.contas if c.codigo == codigo_lucro), None) if conta_lucro is None or not atual.linhas_dre: return [] - lucro_balancete = -conta_lucro.saldo_atual + lucro_balancete = conta_lucro.saldo_atual if contabit else -conta_lucro.saldo_atual lucro_dre = atual.linhas_dre[-1].valor diferenca = lucro_balancete - lucro_dre if diferenca == 0: @@ -308,7 +370,7 @@ def regra_variacao_atipica_dre(atual: ResultadoExtracao, historico: list[Snapsho AchadoDetectado( regra="variacao_atipica_dre", severidade=SEVERIDADE_BAIXA, - titulo="Variação atípica na DRE", + titulo=TITULO_VARIACAO_ATIPICA_DRE, mensagem=( f'Linha "{linha.descricao}" da DRE foi de {mes_anterior.percentual:.2f}% da Receita Bruta em ' f"{rotulo_anterior} para {mes_atual.percentual:.2f}% em {rotulo_atual} " diff --git a/portal_api/migrations/0080_contabil_apuracao_leiaute.py b/portal_api/migrations/0080_contabil_apuracao_leiaute.py new file mode 100644 index 0000000..cd9ba42 --- /dev/null +++ b/portal_api/migrations/0080_contabil_apuracao_leiaute.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.7 on 2026-09-23 18:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('portal_api', '0079_contabil_ordering_determinista'), + ] + + operations = [ + migrations.AddField( + model_name='contabilapuracao', + name='leiaute', + field=models.CharField(choices=[('questor', 'Questor'), ('contabit', 'Contabit')], db_default='questor', max_length=20, verbose_name='Modelo do arquivo'), + ), + migrations.AlterField( + model_name='contabilconta', + name='conta_numero', + field=models.IntegerField(blank=True, null=True, verbose_name='Número da conta'), + ), + ] diff --git a/portal_api/migrations/0081_contabil_titulo_variacao_analise_vertical.py b/portal_api/migrations/0081_contabil_titulo_variacao_analise_vertical.py new file mode 100644 index 0000000..b00054f --- /dev/null +++ b/portal_api/migrations/0081_contabil_titulo_variacao_analise_vertical.py @@ -0,0 +1,32 @@ +"""Renomeia o título dos apontamentos de `variacao_atipica_dre` já gravados +para "Variação atípica na DRE - Análise Vertical" (pedido do usuário). Só o +`titulo`, que é um texto fixo da regra; a `mensagem` (que tem números +interpolados) não é tocada. Texto duplicado aqui de propósito: migração não +importa código de aplicação.""" + +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps + +TITULO_ANTIGO = "Variação atípica na DRE" +TITULO_NOVO = "Variação atípica na DRE - Análise Vertical" + + +def renomeia(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None: + ContabilAchado = apps.get_model("portal_api", "ContabilAchado") + ContabilAchado.objects.filter(regra="variacao_atipica_dre", titulo=TITULO_ANTIGO).update(titulo=TITULO_NOVO) + + +def desfaz(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None: + ContabilAchado = apps.get_model("portal_api", "ContabilAchado") + ContabilAchado.objects.filter(regra="variacao_atipica_dre", titulo=TITULO_NOVO).update(titulo=TITULO_ANTIGO) + + +class Migration(migrations.Migration): + dependencies = [ + ("portal_api", "0080_contabil_apuracao_leiaute"), + ] + + operations = [ + migrations.RunPython(renomeia, desfaz), + ] diff --git a/portal_api/models.py b/portal_api/models.py index 183600b..80cc050 100644 --- a/portal_api/models.py +++ b/portal_api/models.py @@ -2053,6 +2053,28 @@ class ContabilApuracao(models.Model): # este campo só pra mostrar um aviso ao lado do nome da empresa, pedindo # atenção a esse risco, não pra bloquear nada. fonte_pdf_atipica = models.BooleanField("PDF de fonte atípica (risco de nomenclatura)", default=False) + # Sistema que gerou o PDF, detectado pelo parser (nunca informado no + # upload) — ver `dashboard_contabil.modelos.LEIAUTE_*`. Muda os códigos + # fixos das regras, a convenção de sinal e se a apuração mostra + # indicadores (`tem_indicadores`). + LEIAUTE_QUESTOR = "questor" + LEIAUTE_CONTABIT = "contabit" + LEIAUTE_CHOICES = [ + (LEIAUTE_QUESTOR, "Questor"), + (LEIAUTE_CONTABIT, "Contabit"), + ] + # `db_default` (não só `default`): o banco é compartilhado com a produção, + # e um processo ainda rodando o código anterior a este campo não o envia + # no INSERT — o próprio Postgres preenche. + leiaute = models.CharField("Modelo do arquivo", max_length=20, choices=LEIAUTE_CHOICES, db_default=LEIAUTE_QUESTOR) + + @property + def tem_indicadores(self) -> bool: + """Empresas do Contabit não usam indicadores por enquanto (decisão + explícita do usuário): os indicadores cadastrados são calibrados no + plano de contas do Questor e sairiam errados. A seção some da aba + "Dashboard", do relatório e do PDF do Resumo.""" + return self.leiaute != self.LEIAUTE_CONTABIT class Meta: verbose_name = "Apuração do Relatório Contábil" @@ -2105,7 +2127,8 @@ class ContabilConta(models.Model): # criação, mesmo papel de ContabilLinhaDre.ordem) e é o que garante que # a árvore do Balancete (ver dashboard-contabil.js) não misture grupos. ordem = models.IntegerField("Ordem", default=0) - conta_numero = models.IntegerField("Número da conta") + # Nulo só no leiaute Contabit, onde a conta sintética não tem número. + conta_numero = models.IntegerField("Número da conta", null=True, blank=True) codigo = models.CharField("Classificação", max_length=60) descricao = models.CharField("Descrição", max_length=255) tipo = models.CharField("Tipo", max_length=1) # "S" sintética | "A" analítica diff --git a/portal_api/serializers.py b/portal_api/serializers.py index 821692c..18b0f96 100644 --- a/portal_api/serializers.py +++ b/portal_api/serializers.py @@ -2493,6 +2493,14 @@ class ContabilAchadoAjusteSerializer(serializers.Serializer): observacao_contador = serializers.CharField(allow_blank=False) +class ContabilAchadoValidarSerializer(serializers.Serializer): + """Corpo de `ContabilAchadoViewSet.validar()` — só para variação atípica + da Análise Vertical, que é tratada sem justificativa escrita (o texto + dessas variações mora em `ContabilObservacao` da linha, ver a view).""" + + validado = serializers.BooleanField() + + class ContabilAchadoOcultoSerializer(serializers.Serializer): """Corpo de `ContabilAchadoViewSet.alternar_oculto()` — separado de `ContabilAchadoAjusteSerializer` porque ocultar do relatório não deve @@ -2734,6 +2742,7 @@ class ContabilApuracaoListSerializer(serializers.ModelSerializer): "concluida_em", "total_achados_pendentes", "fonte_pdf_atipica", + "leiaute", "reprocessamentos", ] @@ -2747,6 +2756,7 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer): linhas_dre = ContabilLinhaDreSerializer(many=True, read_only=True) linhas_analise_vertical = ContabilLinhaAnaliseVerticalSerializer(many=True, read_only=True) achados = ContabilAchadoSerializer(many=True, read_only=True) + tem_indicadores = serializers.BooleanField(read_only=True) class Meta: model = ContabilApuracao @@ -2764,6 +2774,8 @@ class ContabilApuracaoDetailSerializer(serializers.ModelSerializer): "concluida_em", "resumo_fechamento", "fonte_pdf_atipica", + "leiaute", + "tem_indicadores", "contas", "linhas_dre", "analise_vertical_meses", diff --git a/portal_api/views.py b/portal_api/views.py index b1c9f8a..a558ece 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -49,6 +49,7 @@ from .dashboard_contabil import resumo_pdf as dashboard_contabil_resumo_pdf from .templatetags import contabil_extras as dashboard_contabil_extras from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido from .dashboard_contabil.modelos import SnapshotHistorico as ContabilSnapshotHistorico +from .dashboard_contabil.parser import CodigoEmpresaAusenteError as ContabilCodigoEmpresaAusenteError from .dashboard_contabil.parser import ExtracaoInvalidaError as ContabilExtracaoInvalidaError from .models import ( AcessoGeral, @@ -133,6 +134,7 @@ from .serializers import ( CompromissoAgendaSerializer, ContabilAchadoAjusteSerializer, ContabilAchadoOcultoSerializer, + ContabilAchadoValidarSerializer, ContabilAchadoSerializer, ContabilApuracaoCreateSerializer, ContabilApuracaoDetailSerializer, @@ -3730,10 +3732,16 @@ def _contabil_dados_resumo(apuracao: ContabilApuracao) -> dict[str, Any]: (`ContabilApuracaoViewSet.resumo_pdf()`, ver `resumo_pdf.py`). Devolve `observacoes_visiveis` **sem** separar por `alvo_tipo` nem calcular `ancora` — isso é específico de cada consumidor (o relatório HTML separa - e pendura `ancora` pra permitir o clique-até-a-conta; o PDF só lista).""" - dados = _contabil_coleta_dados_indicadores(apuracao) - valores_indicadores = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados) - cards = _contabil_monta_cards_indicadores(apuracao, valores_indicadores) + e pendura `ancora` pra permitir o clique-até-a-conta; o PDF só lista). + Apuração sem indicadores (`tem_indicadores`, leiaute Contabit) devolve + `indicadores_grupos` vazio, sem nem calcular.""" + indicadores_grupos: list[dict[str, Any]] = [] + if apuracao.tem_indicadores: + dados = _contabil_coleta_dados_indicadores(apuracao) + valores_indicadores = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados) + indicadores_grupos = _contabil_agrupa_indicadores_cards( + _contabil_monta_cards_indicadores(apuracao, valores_indicadores) + ) achados = list(apuracao.achados.select_related("conta", "tratado_por").order_by("severidade", "id")) # Observações que o contador marcou pra mostrar ao cliente, já no recorte # de vigência desta competência (ver `ContabilObservacao`) — inclui as @@ -3747,7 +3755,7 @@ def _contabil_dados_resumo(apuracao: ContabilApuracao) -> dict[str, Any]: if observacao.mostrar_ao_cliente ] return { - "indicadores_grupos": _contabil_agrupa_indicadores_cards(cards), + "indicadores_grupos": indicadores_grupos, "observacoes_visiveis": observacoes_visiveis, # Cards que o contador escondeu na aba "Dashboard" da tela de revisão # (ver `indicadores_ocultos()`) já ficam de fora de `cards` acima; @@ -4176,18 +4184,10 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet): try: resultado = dashboard_contabil_pipeline.processa_apuracao( - io.BytesIO(conteudo), _contabil_monta_historico - ) - except ContabilExtracaoInvalidaError: - return Response( - { - "detail": ( - "O arquivo não está no formato esperado (Balancete + DRE do Questor). " - "Contate o setor de Inovação." - ) - }, - status=status.HTTP_400_BAD_REQUEST, + io.BytesIO(conteudo), _contabil_monta_historico, arquivo.name ) + except ContabilExtracaoInvalidaError as exc: + return _contabil_resposta_extracao_invalida(exc) cabecalho = resultado.extracao.cabecalho competencia = cabecalho.periodo_fim.replace(day=1) @@ -4211,6 +4211,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet): criado_por=request.user, analise_vertical_meses=resultado.extracao.meses_analise_vertical, fonte_pdf_atipica=resultado.extracao.fonte_pdf_atipica, + leiaute=resultado.extracao.leiaute, ) ContabilConta.objects.bulk_create( [ @@ -4339,18 +4340,10 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet): try: resultado = dashboard_contabil_pipeline.processa_apuracao( - io.BytesIO(conteudo), _contabil_monta_historico - ) - except ContabilExtracaoInvalidaError: - return Response( - { - "detail": ( - "O arquivo não está no formato esperado (Balancete + DRE do Questor). " - "Contate o setor de Inovação." - ) - }, - status=status.HTTP_400_BAD_REQUEST, + io.BytesIO(conteudo), _contabil_monta_historico, arquivo.name ) + except ContabilExtracaoInvalidaError as exc: + return _contabil_resposta_extracao_invalida(exc) cabecalho = resultado.extracao.cabecalho nova_competencia = cabecalho.periodo_fim.replace(day=1) @@ -4358,8 +4351,9 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet): return Response( { "detail": ( - f"O arquivo anexado é de {cabecalho.nome_empresa} ({nova_competencia:%m/%Y}), " - f"diferente desta análise ({apuracao.nome_empresa} — {apuracao.competencia:%m/%Y}). " + f"O arquivo anexado é de {cabecalho.codigo_empresa} - {cabecalho.nome_empresa} " + f"({nova_competencia:%m/%Y}), diferente desta análise ({apuracao.codigo_empresa} - " + f"{apuracao.nome_empresa} — {apuracao.competencia:%m/%Y}). " "Reprocessar exige o mesmo período/empresa — pra outra competência, crie uma nova análise." ) }, @@ -4376,6 +4370,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet): apuracao.periodo_fim = cabecalho.periodo_fim apuracao.analise_vertical_meses = resultado.extracao.meses_analise_vertical apuracao.fonte_pdf_atipica = resultado.extracao.fonte_pdf_atipica + apuracao.leiaute = resultado.extracao.leiaute apuracao.arquivo = ContentFile(conteudo, name=arquivo.name) apuracao.save() novo_arquivo_salvo = True @@ -4565,6 +4560,18 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet): de verdade — não têm mais tratamento especial aqui, ver CLAUDE.md do pacote.""" apuracao = self.get_object() + if not apuracao.tem_indicadores: + # Leiaute Contabit: a aba "Dashboard" nem mostra a seção (ver + # `ContabilApuracao.tem_indicadores`), isto só garante que uma + # chamada direta também não calcule nada. + return Response( + { + "indicadores": {}, + "indicadores_ocultos": apuracao.indicadores_ocultos, + "indicadores_selecionados": apuracao.indicadores_selecionados, + "metadados": {}, + } + ) dados = _contabil_coleta_dados_indicadores(apuracao) selecionados = apuracao.indicadores_selecionados valores = _contabil_calcula_indicadores_personalizados(dados, selecionados) @@ -4771,6 +4778,20 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet): return response +def _contabil_resposta_extracao_invalida(exc: ContabilExtracaoInvalidaError) -> Response: + """400 de `create()`/`reprocessar()` quando o PDF não pôde ser lido. + Código da empresa ausente no nome do arquivo (leiaute Contabit) tem + mensagem própria: o PDF está certo, só o nome do arquivo precisa mudar.""" + if isinstance(exc, ContabilCodigoEmpresaAusenteError): + detalhe = str(exc) + else: + detalhe = ( + "O arquivo não está no formato esperado (Balancete + DRE do Questor ou do Contabit). " + "Contate o setor de Inovação." + ) + return Response({"detail": detalhe}, status=status.HTTP_400_BAD_REQUEST) + + def _contabil_garante_em_revisao(apuracao: ContabilApuracao) -> None: if apuracao.status == ContabilApuracao.STATUS_CONCLUIDA: raise ValidationError( @@ -5032,6 +5053,33 @@ class ContabilAchadoViewSet(viewsets.ModelViewSet): achado.save(update_fields=["oculto_no_relatorio"]) return Response(ContabilAchadoSerializer(achado).data) + @action(detail=True, methods=["post"]) + def validar(self, request: Request, pk: str | None = None) -> Response: + """Botão "Validar" das variações atípicas da Análise Vertical (pedido + explícito do usuário): `validado=true` trata o apontamento + (`status="tratado"`) **sem** exigir `observacao_contador`, e + `validado=false` devolve a "pendente". O texto sobre essas variações + não vai no apontamento: é uma `ContabilObservacao` da própria linha da + Análise Vertical, com histórico entre competências e o "mostrar ao + cliente" de sempre. As demais regras continuam só pelo PATCH com + justificativa obrigatória.""" + achado = self.get_object() + _contabil_garante_em_revisao(achado.apuracao) + if achado.regra != "variacao_atipica_dre": + raise ValidationError({"detail": "Só variações atípicas da Análise Vertical são validadas por aqui."}) + entrada = ContabilAchadoValidarSerializer(data=request.data) + entrada.is_valid(raise_exception=True) + if entrada.validated_data["validado"]: + achado.status = ContabilAchado.STATUS_TRATADO + achado.tratado_por = request.user + achado.tratado_em = timezone.now() + else: + achado.status = ContabilAchado.STATUS_PENDENTE + achado.tratado_por = None + achado.tratado_em = None + achado.save(update_fields=["status", "tratado_por", "tratado_em"]) + return Response(ContabilAchadoSerializer(achado).data) + def _gera_chave_indicador_contabil(nome: str) -> str: """Deriva a `chave` de um `IndicadorContabilDefinicao` a partir do diff --git a/static/css/dashboard-contabil.css b/static/css/dashboard-contabil.css index 8beea1c..6f1ab4d 100644 --- a/static/css/dashboard-contabil.css +++ b/static/css/dashboard-contabil.css @@ -717,6 +717,98 @@ flex-wrap: wrap; } +/* Lista "Variação atípica na DRE - Análise Vertical" no fim da aba Análise + Vertical: mesmos cards da aba Observações, com a thread de observação da + linha abrindo numa linha própria do card (flex-basis 100%, o card já é + flex-wrap) e o botão de validar em `--teal`, a mesma cor do "validado" + das contas/linhas. */ +.dc-variacoes__dica { + font-size: 0.8rem; + color: var(--text-muted); + margin: 0 0 var(--space-3); +} + +.dc-variacoes__lista { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.dc-achado-card__actions .dc-conta-observacao-btn { + align-self: center; +} + +.dc-variacao--validada { + border-left-color: var(--teal); +} + +.dc-variacao-validado-btn { + background: var(--teal); + border-color: var(--teal); +} + +.dc-variacao__validado-por { + font-size: 0.8rem; + color: var(--teal); + margin-top: var(--space-2); +} + +.dc-variacao__obs { + flex: 1 1 100%; + min-width: 0; +} + +/* Pulso no card ao chegar pelo ícone da tabela (dcIrParaVariacao) — mesmo + `dcFocoPulse` das linhas, mas por fora do card (não `inset`). */ +@keyframes dcVariacaoFocoPulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); + } + 50% { + box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.9); + } +} + +.dc-variacao--foco { + animation: dcVariacaoFocoPulse 700ms ease-in-out 3; +} + +/* Ícone de variação atípica na coluna de ações da Análise Vertical, antes do + "validado": dourado enquanto pendente, teal depois de validada. */ +.dc-variacao-icone-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: none; + border-radius: 50%; + background: rgba(var(--gold-rgb), 0.16); + color: var(--gold); + cursor: pointer; +} + +.dc-variacao-icone-btn:hover { + background: rgba(var(--gold-rgb), 0.28); +} + +.dc-variacao-icone-btn--validada { + background: rgba(var(--teal-rgb), 0.16); + color: var(--teal); +} + +.dc-variacao-icone-btn--validada:hover { + background: rgba(var(--teal-rgb), 0.28); +} + +/* O tooltip deste ícone carrega a mensagem + até 3 observações. */ +.dc-hover-tooltip--largo .dc-hover-tooltip__texto { + max-width: 400px; + text-align: left; +} + /* Expande/recolhe a conta usada no apontamento (achado.conta) — só presente em achados vinculados a uma conta específica; regras gerais (ex. balanceamento Ativo x Passivo) não têm esse toggle. */ diff --git a/static/js/dashboard-contabil.js b/static/js/dashboard-contabil.js index 27b7cde..1b308db 100644 --- a/static/js/dashboard-contabil.js +++ b/static/js/dashboard-contabil.js @@ -22,7 +22,7 @@ const PID_DC_REGRAS = [ { chave: "conta_transitoria_com_saldo", label: "Contas Transitórias" }, { chave: "conta_deveria_zerar", label: "Contas que Deveriam Zerar" }, { chave: "descricao_generica", label: "Descrição Genérica" }, - { chave: "variacao_atipica_dre", label: "Variação Atípica na DRE" }, + { chave: "variacao_atipica_dre", label: "Variação atípica na DRE - Análise Vertical" }, { chave: "item_removido_reprocessamento", label: "Contas e Linhas Removidas" }, ]; @@ -46,10 +46,8 @@ const PID_DC_GRUPOS = [ label: "Contas Atípicas", regras: ["conta_transitoria_com_saldo", "conta_deveria_zerar", "descricao_generica"], }, - { - label: "Variações e Indicadores", - regras: ["variacao_atipica_dre"], - }, + // "Variações e Indicadores" (variacao_atipica_dre) saiu daqui: essas + // variações só aparecem no fim da aba Análise Vertical. // `somenteComAchados`: só aparece na apuração em que algo foi de fato // removido — um card fixo em "0" em toda análise nunca reprocessada seria // ruído, diferente das regras acima, cujo "0" é a informação de que aquela @@ -364,6 +362,13 @@ async function pidAtualizarAchadoContabil(id, status, observacaoContador) { }); } +// Só variação atípica da Análise Vertical: validar trata o apontamento sem +// justificativa escrita (o texto vai na observação da linha), desvalidar +// volta a "pendente" — ver ContabilAchadoViewSet.validar(). +async function pidValidarAchadoContabil(id, validado) { + return pidApiRequest(`/contabil-achados/${id}/validar/`, { method: "POST", body: { validado } }); +} + // ---- Aba "Dashboard" (pré-visualização do relatório "Gerar Dashboard") ---- async function pidBuscarIndicadoresContabil(id) { @@ -1098,7 +1103,7 @@ document.addEventListener("DOMContentLoaded", async () => { dcDreDestaque = dcUltimaLevaVisivel(apuracao.linhas_dre, (linha) => Math.max(0, linha.nivel), dcDreColapsadas); dcContasExpandidos = new Set(); dcDreExpandidos = new Set(); - dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null }; + dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null, variacao: null }; dcObsEditandoId = null; dcAchadosContaExpandida = new Set(); dcIndicadoresAtual = null; @@ -1137,8 +1142,16 @@ document.addEventListener("DOMContentLoaded", async () => { const PID_DC_SEVERIDADE_ORDEM = { alta: 0, media: 1, baixa: 2 }; + // Variação atípica da Análise Vertical não entra na aba Observações (pedido + // explícito do usuário): já tem lista própria, com validação, no fim da aba + // Análise Vertical (renderVariacoesAnaliseVertical). Vale pra lista, donut + // e cards de categoria. + function dcAchadosDaAbaObservacoes() { + return apuracaoAtual.achados.filter((achado) => achado.regra !== "variacao_atipica_dre"); + } + function achadosFiltrados() { - return apuracaoAtual.achados + return dcAchadosDaAbaObservacoes() .filter((achado) => { if (filtroSeveridade !== "todas" && achado.severidade !== filtroSeveridade) return false; if (filtroStatusAchado === "pendente" && achado.status !== "pendente") return false; @@ -1160,7 +1173,7 @@ document.addEventListener("DOMContentLoaded", async () => { // visão geral estável, mesmo espírito do "Distribuição das Análises" do // sistema externo que inspirou esta seção. function renderAchadosResumo() { - const achados = apuracaoAtual.achados; + const achados = dcAchadosDaAbaObservacoes(); const total = achados.length; // ---- Donut por severidade ---- @@ -1321,8 +1334,7 @@ document.addEventListener("DOMContentLoaded", async () => { </div> <div class="dc-achado-card__actions"> ${temAlvoNaTabela ? `<button type="button" class="btn-outline" data-dc-achado-ir-para="${achado.id}" title="Ir até a linha referida por este apontamento">${PID_DC_ICON_LOCALIZAR} Ver na tabela</button>` : ""} - ${podeEditar ? `<button type="button" class="btn-outline" data-dc-achado-editar="${achado.id}">Revisar</button>` : ""} - </div> + ${podeEditar ? `<button type="button" class="btn-outline" data-dc-achado-editar="${achado.id}">Revisar</button>` : ""} </div> `; lista.appendChild(card); }); @@ -1790,8 +1802,8 @@ document.addEventListener("DOMContentLoaded", async () => { // badges abaixo. `gatilhoHtml` é o ícone (já pronto, com sua própria // classe/cor); `texto` vai só no `<span>` do tooltip (nunca em `title`, // que voltaria a mostrar o balão nativo do navegador em cima do custom). - function pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto) { - return `<span class="dc-hover-tooltip"> + function pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto, classeExtra = "") { + return `<span class="dc-hover-tooltip${classeExtra ? ` ${classeExtra}` : ""}"> <span class="dc-hover-tooltip__gatilho ${gatilhoClasse}" tabindex="0" aria-label="${pidDcEscapeHtml(texto)}">${gatilhoHtml}</span> <span class="dc-hover-tooltip__texto" role="tooltip">${pidDcEscapeHtml(texto)}</span> </span>`; @@ -1870,7 +1882,12 @@ document.addEventListener("DOMContentLoaded", async () => { // Qual linha está com o painel de observações aberto, por tabela (no // máximo uma por tabela, mesmo espírito dos antigos dcContaObsEditId/ // dcDreObsEditId/dcAvObsEditId que este objeto substituiu). - let dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null }; + // `variacao` é o painel aberto dentro de um card da lista "Variações + // atípicas" (fim da aba Análise Vertical): mesma observação da linha da + // Análise Vertical, só exibida em outro lugar — por isso abrir um fecha o + // outro (ver dcTrataCliqueObservacao), senão a mesma thread existiria duas + // vezes na tela com os mesmos ids de campo. + let dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null, variacao: null }; // Observação (id) em edição inline dentro do painel — só uma por vez, e só // enquanto ela for da competência aberta (histórica nunca é editável). let dcObsEditandoId = null; @@ -2117,6 +2134,13 @@ document.addEventListener("DOMContentLoaded", async () => { // embaixo. Uma observação nunca é sobrescrita — escrever de novo cria mais // um item na thread. function dcObsPainelHtml(tipo, alvoId, observacoes, colspan, concluida) { + return `<td colspan="${colspan}">${dcObsPainelConteudoHtml(tipo, alvoId, observacoes, concluida)}</td>`; + } + + // `escopo` separa os ids dos campos e a chave de dcObsPainelAberto quando + // a mesma thread é aberta fora da tabela (card de variação: escopo + // "variacao"); `tipo` continua sendo o alvo_tipo enviado à API. + function dcObsPainelConteudoHtml(tipo, alvoId, observacoes, concluida, escopo = tipo) { const thread = observacoes.length ? observacoes.map((obs) => dcObsItemHtml(obs, concluida)).join("") : '<p class="dc-obs-thread__vazio">Nenhuma observação registrada até agora.</p>'; @@ -2124,28 +2148,26 @@ document.addEventListener("DOMContentLoaded", async () => { ? "" : `<div class="dc-obs-nova"> <div class="modal-field"> - <textarea id="dc-obs-nova-texto-${tipo}-${alvoId}" rows="3" placeholder="Escreva uma observação nova..."></textarea> + <textarea id="dc-obs-nova-texto-${escopo}-${alvoId}" rows="3" placeholder="Escreva uma observação nova..."></textarea> </div> <label class="modal-checkbox"> - <input type="checkbox" id="dc-obs-nova-mostrar-${tipo}-${alvoId}" /> + <input type="checkbox" id="dc-obs-nova-mostrar-${escopo}-${alvoId}" /> Mostrar esta observação ao cliente no relatório </label> - <p class="modal-error" id="dc-obs-nova-erro-${tipo}-${alvoId}"></p> + <p class="modal-error" id="dc-obs-nova-erro-${escopo}-${alvoId}"></p> <div class="modal-actions"> - <button type="button" class="btn-outline" data-dc-obs-fechar="${tipo}">Fechar</button> - <button type="button" class="btn-solid" data-dc-obs-criar="${tipo}:${alvoId}">Salvar observação</button> + <button type="button" class="btn-outline" data-dc-obs-fechar="${escopo}">Fechar</button> + <button type="button" class="btn-solid" data-dc-obs-criar="${tipo}:${alvoId}:${escopo}">Salvar observação</button> </div> </div>`; const rodape = concluida ? '<p class="dc-obs-thread__vazio">Análise concluída, histórico somente leitura.</p>' : ""; return ` - <td colspan="${colspan}"> - <div class="dc-obs-edit"> - <div class="dc-obs-thread">${thread}</div> - ${nova}${rodape} - </div> - </td> + <div class="dc-obs-edit"> + <div class="dc-obs-thread">${thread}</div> + ${nova}${rodape} + </div> `; } @@ -2159,6 +2181,8 @@ document.addEventListener("DOMContentLoaded", async () => { const [tipoBtn, idTexto] = abrirBtn.getAttribute("data-dc-obs-abrir").split(":"); const id = Number(idTexto); dcObsPainelAberto[tipoBtn] = dcObsPainelAberto[tipoBtn] === id ? null : id; + if (tipoBtn === "variacao") dcObsPainelAberto.analise_vertical = null; + if (tipoBtn === "analise_vertical") dcObsPainelAberto.variacao = null; dcObsEditandoId = null; dcRenderTabelaDoTipo(tipo); return true; @@ -2174,11 +2198,12 @@ document.addEventListener("DOMContentLoaded", async () => { const criarBtn = event.target.closest("[data-dc-obs-criar]"); if (criarBtn) { - const [alvoTipo, idTexto] = criarBtn.getAttribute("data-dc-obs-criar").split(":"); + const [alvoTipo, idTexto, escopoTexto] = criarBtn.getAttribute("data-dc-obs-criar").split(":"); const alvoId = Number(idTexto); - const erroEl = document.getElementById(`dc-obs-nova-erro-${alvoTipo}-${alvoId}`); - const texto = document.getElementById(`dc-obs-nova-texto-${alvoTipo}-${alvoId}`).value.trim(); - const mostrar = document.getElementById(`dc-obs-nova-mostrar-${alvoTipo}-${alvoId}`).checked; + const escopo = escopoTexto || alvoTipo; + const erroEl = document.getElementById(`dc-obs-nova-erro-${escopo}-${alvoId}`); + const texto = document.getElementById(`dc-obs-nova-texto-${escopo}-${alvoId}`).value.trim(); + const mostrar = document.getElementById(`dc-obs-nova-mostrar-${escopo}-${alvoId}`).checked; erroEl.textContent = ""; if (!texto) { erroEl.textContent = "Escreva a observação antes de salvar."; @@ -2368,7 +2393,7 @@ document.addEventListener("DOMContentLoaded", async () => { if (dcContasDestaque.has(conta.id)) tr.classList.add("dc-conta-row--destaque"); if (dcFocoLinha && dcFocoLinha.tipo === "conta" && dcFocoLinha.id === conta.id) tr.classList.add("dc-conta-row--foco"); tr.innerHTML = ` - <td>${pidDcEscapeHtml(conta.conta_numero)}</td> + <td>${pidDcEscapeHtml(conta.conta_numero ?? "")}</td> <td>${pidDcEscapeHtml(conta.codigo)}</td> <td style="padding-left: calc(var(--space-4) + ${nivel * 18}px)"> <span class="dc-conta-desc-cell">${toggle}<span>${pidDcEscapeHtml(conta.descricao)}</span></span> @@ -2604,6 +2629,13 @@ document.addEventListener("DOMContentLoaded", async () => { const niveis = linhas.map(nivelFn); const temFilhos = linhas.map((_, i) => i + 1 < linhas.length && niveis[i + 1] > niveis[i]); + const variacoesPorLinha = new Map(); + (apuracaoAtual.achados || []).forEach((achado) => { + if (achado.regra === "variacao_atipica_dre" && achado.linha_analise_vertical != null) { + variacoesPorLinha.set(achado.linha_analise_vertical, achado); + } + }); + const pilhaColapso = []; linhas.forEach((linha, i) => { const nivel = niveis[i]; @@ -2647,6 +2679,7 @@ document.addEventListener("DOMContentLoaded", async () => { ${colunasMensais} <td> <div class="dc-obs-cell-actions"> + ${dcVariacaoIconeHtml(variacoesPorLinha.get(linha.id), observacoesDaLinha)} <button type="button" class="dc-conta-validado-btn${validadoInfo.classe}" data-dc-av-validado="${linha.id}" title="${validadoInfo.title}" aria-label="${validadoInfo.label}" aria-pressed="${validadoInfo.pressed}" ${concluida ? "disabled" : ""}> ${PID_DC_VALIDADO_ICONE} </button> @@ -2667,9 +2700,158 @@ document.addEventListener("DOMContentLoaded", async () => { dcAtualizaBotaoArvore("analise-vertical", dcAvColapsadas, "da Análise Vertical"); renderAnaliseVerticalObsResumo(); + renderVariacoesAnaliseVertical(); } + // Ícone de variação atípica na linha da Análise Vertical (pedido explícito + // do usuário): sinaliza na própria tabela que a linha teve variação + // relevante. No hover mostra a mensagem da variação, o status e as + // observações da linha; no clique leva ao card da variação no fim da aba, + // já com a thread de observação aberta (dcIrParaVariacao). Dourado enquanto + // pendente, teal depois de validada (mesma cor do "validado"). + const PID_DC_VARIACAO_ICONE = + '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg>'; + + function dcVariacaoIconeHtml(achado, observacoes) { + if (!achado) return ""; + const validado = achado.status === "tratado"; + const partes = [ + `${achado.titulo} (${validado ? "validada" : "pendente"})`, + achado.mensagem, + ]; + if (validado && achado.tratado_por_nome) { + partes.push(`Validada por ${achado.tratado_por_nome} em ${pidDcFormatData(achado.tratado_em)}.`); + } + if (observacoes.length) { + partes.push(`\nObservações (${observacoes.length}):`); + observacoes.slice(0, 3).forEach((obs) => { + const texto = obs.texto.length > 160 ? `${obs.texto.slice(0, 157)}...` : obs.texto; + partes.push(`• ${obs.criado_por_nome || "Usuário removido"}, ${pidDcFormatData(obs.criado_em)}: ${texto}`); + }); + if (observacoes.length > 3) partes.push(`... e mais ${observacoes.length - 3}.`); + } else { + partes.push("\nNenhuma observação registrada."); + } + partes.push(validado ? "\nClique para ver ou comentar." : "\nClique para validar e comentar."); + const botao = `<button type="button" class="dc-variacao-icone-btn${validado ? " dc-variacao-icone-btn--validada" : ""}" data-dc-av-variacao="${achado.linha_analise_vertical}" aria-label="Ir para a variação atípica desta linha">${PID_DC_VARIACAO_ICONE}</button>`; + return pidDcHoverTooltipHtml(botao, "", partes.join("\n"), "dc-hover-tooltip--largo"); + } + + // Card da variação que acabou de receber o clique do ícone — destacado por + // um pulso curto, mesmo espírito de dcFocoLinha nas tabelas. + let dcVariacaoFoco = null; + + function dcIrParaVariacao(linhaId) { + dcObsPainelAberto.variacao = linhaId; + dcObsPainelAberto.analise_vertical = null; + dcObsEditandoId = null; + dcVariacaoFoco = linhaId; + renderAnaliseVertical(); + requestAnimationFrame(() => { + const card = document.querySelector(`[data-dc-variacao-linha="${linhaId}"]`); + if (card) card.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + setTimeout(() => { + if (dcVariacaoFoco !== linhaId) return; + dcVariacaoFoco = null; + const card = document.querySelector(`[data-dc-variacao-linha="${linhaId}"]`); + if (card) card.classList.remove("dc-variacao--foco"); + }, 2400); + } + + // Botão "Validar" de uma variação atípica: trata o apontamento sem texto + // (o texto é a observação da linha), clicar de novo volta a "pendente". + function dcValidarVariacaoBtnHtml(achado) { + const validado = achado.status === "tratado"; + const concluida = apuracaoAtual.status === "concluida"; + const rotulo = validado ? "✓ Validado" : "Validar"; + const titulo = validado ? "Clique para voltar a pendente" : "Confirmar que esta variação foi validada"; + return `<button type="button" class="${validado ? "btn-solid dc-variacao-validado-btn" : "btn-outline"}" data-dc-variacao-validar="${achado.id}" title="${titulo}" ${concluida ? "disabled" : ""}>${rotulo}</button>`; + } + + async function dcAlternarValidacaoVariacao(id, botao) { + const achado = apuracaoAtual.achados.find((a) => a.id === id); + if (!achado || botao.disabled) return; + botao.disabled = true; + try { + const atualizado = await pidValidarAchadoContabil(id, achado.status !== "tratado"); + const indice = apuracaoAtual.achados.findIndex((a) => a.id === id); + apuracaoAtual.achados[indice] = atualizado; + // A tabela inteira, não só a lista: o ícone de variação da linha muda + // de cor junto (renderAnaliseVertical já redesenha a lista no fim). + renderAnaliseVertical(); + await carregarLista(); + } catch (e) { + botao.disabled = false; + await pidAlert(e.message); + } + } + + // Lista "Variação atípica na DRE - Análise Vertical" no fim da aba (pedido + // explícito do usuário): um card por apontamento de variacao_atipica_dre, + // com a observação da própria linha (mesma thread da tabela, escopo + // "variacao") e o botão de validar. + function renderVariacoesAnaliseVertical() { + const lista = document.getElementById("dc-av-variacoes-list"); + const vazio = document.getElementById("dc-av-variacoes-empty"); + const variacoes = (apuracaoAtual.achados || []).filter((a) => a.regra === "variacao_atipica_dre"); + const concluida = apuracaoAtual.status === "concluida"; + document.getElementById("dc-av-variacoes-count").textContent = variacoes.length; + vazio.hidden = variacoes.length > 0; + + lista.innerHTML = variacoes + .map((achado) => { + const linhaId = achado.linha_analise_vertical; + const linha = linhaId != null ? (apuracaoAtual.linhas_analise_vertical || []).find((l) => l.id === linhaId) : null; + const observacoes = linha ? dcObservacoesDe("analise_vertical", dcChaveObsLinha(linha)) : []; + const painelAberto = linha && dcObsPainelAberto.variacao === linhaId; + const validadoPor = + achado.status === "tratado" && achado.tratado_por_nome + ? `<div class="dc-variacao__validado-por">Validado por ${pidDcEscapeHtml(achado.tratado_por_nome)} em ${pidDcFormatData(achado.tratado_em)}</div>` + : ""; + return ` + <div class="dc-achado-card dc-achado-card--${achado.severidade} dc-variacao${achado.status === "tratado" ? " dc-variacao--validada" : ""}${linhaId != null && dcVariacaoFoco === linhaId ? " dc-variacao--foco" : ""}" data-dc-variacao-linha="${linhaId ?? ""}"> + <div class="dc-achado-card__main"> + <div class="dc-achado-card__titulo"> + <span class="dc-badge dc-badge--${achado.status}">${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status}</span> + ${pidDcEscapeHtml(achado.titulo)} + </div> + <div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</div> + ${validadoPor} + </div> + <div class="dc-achado-card__actions"> + ${linha ? `<button type="button" class="btn-outline" data-dc-achado-ir-para="${achado.id}" title="Ir até a linha referida por este apontamento">${PID_DC_ICON_LOCALIZAR} Ver na tabela</button>` : ""} + ${linha ? dcObsBotaoHtml("variacao", linhaId, observacoes, false) : ""} + ${dcValidarVariacaoBtnHtml(achado)} + </div> + ${painelAberto ? `<div class="dc-variacao__obs">${dcObsPainelConteudoHtml("analise_vertical", linhaId, observacoes, concluida, "variacao")}</div>` : ""} + </div> + `; + }) + .join(""); + } + + document.getElementById("dc-av-variacoes-list").addEventListener("click", async (event) => { + const validarBtn = event.target.closest("[data-dc-variacao-validar]"); + if (validarBtn) { + await dcAlternarValidacaoVariacao(Number(validarBtn.getAttribute("data-dc-variacao-validar")), validarBtn); + return; + } + const irParaBtn = event.target.closest("[data-dc-achado-ir-para]"); + if (irParaBtn) { + const achado = apuracaoAtual.achados.find((a) => a.id === Number(irParaBtn.getAttribute("data-dc-achado-ir-para"))); + if (achado && achado.linha_analise_vertical != null) dcIrParaLinha("av", achado.linha_analise_vertical); + return; + } + await dcTrataCliqueObservacao(event, "analise_vertical"); + }); + document.getElementById("dc-av-body").addEventListener("click", async (event) => { + const variacaoBtn = event.target.closest("[data-dc-av-variacao]"); + if (variacaoBtn) { + dcIrParaVariacao(Number(variacaoBtn.getAttribute("data-dc-av-variacao"))); + return; + } const toggleBtn = event.target.closest("[data-dc-av-toggle]"); if (toggleBtn) { const id = Number(toggleBtn.getAttribute("data-dc-av-toggle")); @@ -2998,6 +3180,16 @@ document.addEventListener("DOMContentLoaded", async () => { // esta aba). Observações (contas/DRE/achados) já estão todas carregadas // em apuracaoAtual, não precisam de fetch próprio. async function renderDashboardTab() { + // Apuração do Contabit não usa indicadores (ContabilApuracao.tem_indicadores): + // some o botão "Gerenciar Indicadores", a dica e os cards, sem buscar nada. + const temIndicadores = apuracaoAtual.tem_indicadores !== false; + ["dc-indicadores-gerenciar-btn", "dc-dash-indicadores-dica", "dc-dash-indicadores"].forEach((id) => { + document.getElementById(id).hidden = !temIndicadores; + }); + if (!temIndicadores) { + renderDashboardObservacoes(); + return; + } if (!dcIndicadoresAtual) { dcIndicadoresAtual = await pidBuscarIndicadoresContabil(apuracaoAtual.id); } diff --git a/templates/dashboard-contabil-relatorio.html b/templates/dashboard-contabil-relatorio.html index 9c6b8e8..2b2cee2 100644 --- a/templates/dashboard-contabil-relatorio.html +++ b/templates/dashboard-contabil-relatorio.html @@ -973,6 +973,7 @@ <div class="dcr-resumo-fechamento__texto">{{ apuracao.resumo_fechamento|safe }}</div> </div> {% endif %} + {% if indicadores_grupos %} <div class="dcr-secao"> {% for grupo in indicadores_grupos %} <h2> @@ -1007,6 +1008,7 @@ </div> {% endfor %} </div> + {% endif %} <div class="dcr-secao"> <h2> diff --git a/templates/dashboard-contabil.html b/templates/dashboard-contabil.html index 5b6dcce..0f12286 100644 --- a/templates/dashboard-contabil.html +++ b/templates/dashboard-contabil.html @@ -334,7 +334,7 @@ <div class="dc-header"> <div> <h2>Relatório Contábil</h2> - <p class="dc-subtitle">Anexe o balancete + DRE em PDF (modelo Questor) de uma empresa — a ferramenta extrai as contas, roda um conjunto de checagens automáticas e apresenta as observações para análise antes da conclusão.</p> + <p class="dc-subtitle">Anexe o balancete + DRE em PDF (modelo Questor ou Contabit) de uma empresa — a ferramenta extrai as contas, roda um conjunto de checagens automáticas e apresenta as observações para análise antes da conclusão. No modelo Contabit, o nome do arquivo precisa começar pelo código da empresa (ex.: "1512 - Balancete 082026.pdf").</p> </div> <button type="button" class="btn-solid" id="dc-new-btn"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14" stroke-linecap="round"/></svg> @@ -563,6 +563,12 @@ <div class="dc-dash-obs-list" id="dc-av-obs-list"></div> <p class="dc-empty" id="dc-av-obs-empty" hidden>Nenhuma observação registrada na Análise Vertical ainda.</p> </section> + <section class="dc-obs-resumo dc-variacoes"> + <h3 class="dc-obs-resumo__titulo">Variação atípica na DRE - Análise Vertical <span class="dc-obs-resumo__count" id="dc-av-variacoes-count">0</span></h3> + <p class="dc-variacoes__dica">Confira cada variação e clique em "Validar". A observação escrita aqui é a mesma da linha na tabela: fica no histórico da empresa e pode ser mostrada ao cliente.</p> + <div class="dc-variacoes__lista" id="dc-av-variacoes-list"></div> + <p class="dc-empty" id="dc-av-variacoes-empty" hidden>Nenhuma variação atípica nesta análise.</p> + </section> </div> <div class="pa-tab-panel" data-dc-panel="dashboard" hidden> @@ -586,7 +592,7 @@ </div> </div> - <p class="dc-dash-section__dica">Clique num card pra ver a fórmula usada.</p> + <p class="dc-dash-section__dica" id="dc-dash-indicadores-dica">Clique num card pra ver a fórmula usada.</p> <div id="dc-dash-indicadores"></div> </section>