Implementação do relatório Contábil - Contabit
This commit is contained in:
parent
e4d95266ef
commit
df1f4e6a1f
@ -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",
|
||||
|
||||
@ -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`.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`, `<title>`/`<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.
|
||||
|
||||
@ -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).
|
||||
|
||||
56
portal_api/dashboard_contabil/conversao.py
Normal file
56
portal_api/dashboard_contabil/conversao.py
Normal file
@ -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))
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
342
portal_api/dashboard_contabil/parser_contabit.py
Normal file
342
portal_api/dashboard_contabil/parser_contabit.py
Normal file
@ -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,
|
||||
)
|
||||
@ -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)
|
||||
|
||||
@ -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} "
|
||||
|
||||
23
portal_api/migrations/0080_contabil_apuracao_leiaute.py
Normal file
23
portal_api/migrations/0080_contabil_apuracao_leiaute.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user