Reestruturação Conciliação de Fornecedores

This commit is contained in:
Gabriel 2026-09-28 17:55:51 -03:00
parent 9ff4329758
commit 4ab224d9b3
12 changed files with 500 additions and 43 deletions

View File

@ -345,7 +345,7 @@ Ver `portal_api/nao_conformidades/CLAUDE.md`.
## Conciliação de Fornecedores (Utilitários) ## Conciliação de Fornecedores (Utilitários)
O contador informa o código da empresa (nome buscado no Questor) e anexa o razão contábil de fornecedores exportado do Questor; a ferramenta vincula pagamentos e títulos de cada fornecedor por valor e data (inclusive combinações e parcelas) e aponta o que está pendente: títulos em aberto há mais de 2 meses, pagamentos sem título (divergência), diferenças de juros/desconto e conta transitória com saldo. Vínculo manual, validação por conta, histórico salvo e exportação XLSX. Restrita ao perfil Inovação. O contador informa o código da empresa (nome buscado no Questor) e anexa o razão contábil de fornecedores exportado do Questor; a ferramenta vincula pagamentos e títulos de cada fornecedor por valor e data (inclusive combinações e parcelas) e aponta o que está pendente: pagamentos sem título (divergência), diferenças de juros/desconto e conta transitória com saldo; conta só com títulos em aberto é conciliada, e os títulos em aberto há mais de 3 meses são revisados numa aba à parte, com validação própria por fornecedor. Vínculo manual, validação por conta, histórico salvo e exportação XLSX. Restrita ao perfil Inovação.
Ver `portal_api/conciliacao_fornecedores/CLAUDE.md`. Ver `portal_api/conciliacao_fornecedores/CLAUDE.md`.

View File

@ -160,3 +160,18 @@ Pergunta do usuário (BRASPRESS): o pagamento de R$ 234,40 era exatamente a soma
### Rodada 33 — Demais fornecedores apagados com um expandido (2026-09-28) ### Rodada 33 — Demais fornecedores apagados com um expandido (2026-09-28)
Pedido do usuário: ao expandir um fornecedor, deixar os demais "apagados" para destacar o que está em análise. Só CSS (`conciliacao-fornecedores.css`): enquanto houver alguma `tr.conc-row.is-aberta` em `#conc-contas-body` (seletor `:has()`), as linhas de fornecedor não expandidas ganham o fundo `--bg-canvas` (o tom mais escuro do tema, mesmo das linhas em destaque do Relatório Contábil), fora do hover, que continua com o `--card-bg-hover` de sempre. A primeira versão só apagava por opacidade (0.35); o usuário pediu esse tom de fundo e, em seguida, que o texto continuasse apagado como antes: o fundo vai no `tr` e a opacidade 0.35 no `td` (com fundo transparente), pra opacidade não clarear o fundo junto. Com mais de um expandido, todos os expandidos ficam em destaque. Pedido do usuário: ao expandir um fornecedor, deixar os demais "apagados" para destacar o que está em análise. Só CSS (`conciliacao-fornecedores.css`): enquanto houver alguma `tr.conc-row.is-aberta` em `#conc-contas-body` (seletor `:has()`), as linhas de fornecedor não expandidas ganham o fundo `--bg-canvas` (o tom mais escuro do tema, mesmo das linhas em destaque do Relatório Contábil), fora do hover, que continua com o `--card-bg-hover` de sempre. A primeira versão só apagava por opacidade (0.35); o usuário pediu esse tom de fundo e, em seguida, que o texto continuasse apagado como antes: o fundo vai no `tr` e a opacidade 0.35 no `td` (com fundo transparente), pra opacidade não clarear o fundo junto. Com mais de um expandido, todos os expandidos ficam em destaque.
### Rodada 34 — Só títulos em aberto é conciliada; tela à parte para os títulos antigos (2026-09-28)
Pedido do usuário (AUDIOFRAHM: 1 título de R$ 4.853,89 em aberto há mais de 3 meses, nenhum pagamento, conta em "Atenção"): (1) se há só saldo em aberto, sem pagamentos para conciliar, considerar conciliada automaticamente; (2) uma tela à parte para validar os títulos em aberto há mais de 3 meses; (3) essa situação não é bloqueio para análise, então não deve deixar a conta em "Atenção".
Decisões confirmadas com o usuário: validação da tela nova **por fornecedor** (não por título); chip "Pendentes de pagamento" **removido**; conciliações já salvas também recebem a validação automática, **ao abrir**.
O que mudou:
- `alertas.py`: o alerta `credito_vencido` saiu (os títulos antigos continuam na categoria `vencido` e em `total_vencido`); `STATUS_PENDENTE` saiu, e sem alerta a conta é `conciliada`, mesmo com títulos em aberto.
- Model: `vencidos_validados`/`vencidos_validados_por`/`vencidos_validados_em`/`vencidos_observacao` em `ConciliacaoFornecedorConta` (migração `0086_conciliacao_vencidos_validados`, aplicada). Endpoint novo `POST /conciliacoes-fornecedores-contas/{id}/validar-vencidos/`. Payload da conta com esses campos e `qtd_vencidos`; `resumo` com `contas_vencidas` (agora por `total_vencido > 0`) e `contas_vencidas_validadas`.
- `retrieve` reaplica a validação automática em todas as contas e regrava o `resumo` se mudou.
- XLSX: duas colunas novas no Resumo ("Títulos > 3 meses validados", vazia para quem não tem título antigo, e a observação).
- Tela: abas "Conciliação" / "Em aberto há mais de 3 meses" no detalhe; o card de vencidos abre a aba nova; a linha de fornecedor sem alerta diz "N título(s) em aberto, sem pagamento a conciliar".
Efeito na conciliação 20 (empresa 2017, testado dentro de transação desfeita): antes 146 conciliadas / 52 pendentes / 61 atenção / 1 divergente, 149 validadas; depois 233 conciliadas / 26 atenção / 1 divergente, 234 validadas (as 87 conciliadas com título em aberto, todas validadas automaticamente). 57 fornecedores com título antigo na aba nova. `validar-vencidos/` testado sem alterar a validação da conciliação; segunda abertura não sincroniza mais nada. A tela não foi renderizada neste ambiente.

View File

@ -48,22 +48,24 @@ As sugestões saem de uma **distribuição** dos pagamentos não vinculados sobr
| Categoria | Significado (fato) | Alerta | | Categoria | Significado (fato) | Alerta |
|---|---|---| |---|---|---|
| `em_aberto` | título sem pagamento vinculado (ou saldo de um pagamento parcial vinculado) | nenhum (conta fica "Pendente de pagamento") | | `em_aberto` | título sem pagamento vinculado (ou saldo de um pagamento parcial vinculado) | nenhum |
| `vencido` | idem, há mais de 3 meses até a data-base | média (`credito_vencido`, valor real) | | `vencido` | idem, há mais de 3 meses até a data-base | **nenhum** (desde a rodada 34 de `CHANGELOG.md` nesta pasta: "não é bloqueio para análise"; vai para `total_vencido` e para a aba "Em aberto há mais de 3 meses") |
| `nao_vinculado` | pagamento ainda não vinculado a um título | média (`pagamento_sem_titulo`, "N pagamentos ainda não vinculados, somando R$ X") | | `nao_vinculado` | pagamento ainda não vinculado a um título | média (`pagamento_sem_titulo`, "N pagamentos ainda não vinculados, somando R$ X") |
| `excedente` | pagamento além de todos os títulos em aberto | **alta (divergência)** | | `excedente` | pagamento além de todos os títulos em aberto | **alta (divergência)** |
Consequência nos totais: `total_em_aberto` (card "Títulos em aberto") é a soma real dos títulos sem baixa e pode passar do saldo contábil da conta quando há pagamentos a vincular. O card mostra os dois (`total_nao_vinculado` = "em pagamentos a vincular"); o saldo contábil continua na coluna "Saldo residual". Consequência nos totais: `total_em_aberto` (card "Títulos em aberto") é a soma real dos títulos sem baixa e pode passar do saldo contábil da conta quando há pagamentos a vincular. O card mostra os dois (`total_nao_vinculado` = "em pagamentos a vincular"); o saldo contábil continua na coluna "Saldo residual".
Alertas de conta: `saldo_divergente_arquivo`, `conta_transitoria`, `saldo_devedor` (alta); `debito_excedente` (alta); `credito_vencido`, `pagamento_sem_titulo` (média); `diferenca_vinculo` (baixa, vínculo automático por tolerância ou manual com diferença; diferença negativa = pago a maior, possível juros/multa; positiva = pago a menor, possível desconto). Alertas de conta: `saldo_divergente_arquivo`, `conta_transitoria`, `saldo_devedor` (alta); `debito_excedente` (alta); `pagamento_sem_titulo` (média); `diferenca_vinculo` (baixa, vínculo automático por tolerância ou manual com diferença; diferença negativa = pago a maior, possível juros/multa; positiva = pago a menor, possível desconto).
Status: `divergente` (algum alerta alto) > `atencao` (médio/baixo) > `pendente` (só títulos recentes em aberto) > `conciliada`. Status: `divergente` (algum alerta alto) > `atencao` (médio/baixo) > `conciliada` (nenhum alerta). **Só títulos em aberto, sem pagamento a conciliar, é `conciliada`** (pedido do usuário, rodada 34): o que falta ali é o pagamento acontecer, não uma análise. A situação "Pendente de pagamento" (`pendente`) deixou de existir, e o chip dela saiu da tela; o `resumo` gravado de uma conciliação antiga ainda pode ter a chave `pendente` até ela ser aberta de novo.
**Validação é uma marcação à parte e nunca muda a situação** (pedido do usuário): uma conta em "Atenção" validada continua "Atenção", com o checkbox marcado. Conta "Conciliada" já nasce validada (`_conciliacao_valida_conciliadas()` na criação). A validação automática é a que tem `validada_por` nulo (`validada_automatica` no payload): depois de vincular/desfazer, `_conciliacao_sincroniza_validacao_automatica()` a aplica se a conta ficou conciliada e a retira se deixou de estar; a validação feita por uma pessoa (endpoint `validar/`) nunca é mexida por essa regra. **Validação é uma marcação à parte e nunca muda a situação** (pedido do usuário): uma conta em "Atenção" validada continua "Atenção", com o checkbox marcado. Conta "Conciliada" já nasce validada (`_conciliacao_valida_conciliadas()` na criação). A validação automática é a que tem `validada_por` nulo (`validada_automatica` no payload): depois de vincular/desfazer, `_conciliacao_sincroniza_validacao_automatica()` a aplica se a conta ficou conciliada e a retira se deixou de estar; a validação feita por uma pessoa (endpoint `validar/`) nunca é mexida por essa regra. **Ao abrir uma conciliação** (`retrieve`), a mesma sincronização roda em todas as contas e o `resumo` é regravado se mudou (pedido do usuário, para as conciliações anteriores à regra nova de "conciliada" também receberem a validação automática).
**Revisão dos títulos antigos é outra validação, por fornecedor** (pedido do usuário): `vencidos_validados`/`vencidos_validados_por`/`vencidos_validados_em`/`vencidos_observacao` na conta (migração `0086`), gravados por `validar-vencidos/`. Independente de `validada`: validar uma não mexe na outra, e a validação automática nunca toca nesses campos. Não é zerada se os títulos antigos mudarem (ex.: um deles for vinculado depois); se o fornecedor deixar de ter título antigo, só sai da aba.
## Models (`portal_api/models.py`) ## Models (`portal_api/models.py`)
`ConciliacaoFornecedor` (cabeçalho: empresa, data-base, período, arquivo, tolerâncias, `resumo` JSON) → `ConciliacaoFornecedorConta` (saldos, `validada`/`validada_por`/`validada_em`, `observacao`) → `ConciliacaoFornecedorVinculo` (`tipo` exato/combinacao/saldo_zerado/tolerancia/manual/**parcial**, `diferenca` = créditos − débitos, `criado_por` só no manual) e `ConciliacaoFornecedorLancamento` (`vinculo` FK `SET_NULL`: excluir o vínculo devolve os lançamentos para pendente). `ConciliacaoFornecedorVinculoHistorico` (por conta): cada ação manual sobre vínculo (`acao` vinculou/acrescentou/desfez, este último inclusive para vínculo automático), com `usuario`, `criado_em`, `tipo_rotulo`/`vinculo_automatico` e **uma cópia dos lançamentos envolvidos** em `lancamentos` (JSON com seq., data, natureza, valor e `acrescentado`), porque o vínculo desfeito é apagado. Gravado por `_conciliacao_registra_historico()` dentro da mesma transação da ação. Vínculos criados pelo motor na importação **não** entram (não são ação de ninguém); ações anteriores à rodada 23 não têm registro. `ConciliacaoFornecedor` (cabeçalho: empresa, data-base, período, arquivo, tolerâncias, `resumo` JSON) → `ConciliacaoFornecedorConta` (saldos, `validada`/`validada_por`/`validada_em`, `observacao`, e a validação à parte dos títulos antigos `vencidos_validados`/`_por`/`_em`/`vencidos_observacao`) → `ConciliacaoFornecedorVinculo` (`tipo` exato/combinacao/saldo_zerado/tolerancia/manual/**parcial**, `diferenca` = créditos − débitos, `criado_por` só no manual) e `ConciliacaoFornecedorLancamento` (`vinculo` FK `SET_NULL`: excluir o vínculo devolve os lançamentos para pendente). `ConciliacaoFornecedorVinculoHistorico` (por conta): cada ação manual sobre vínculo (`acao` vinculou/acrescentou/desfez, este último inclusive para vínculo automático), com `usuario`, `criado_em`, `tipo_rotulo`/`vinculo_automatico` e **uma cópia dos lançamentos envolvidos** em `lancamentos` (JSON com seq., data, natureza, valor e `acrescentado`), porque o vínculo desfeito é apagado. Gravado por `_conciliacao_registra_historico()` dentro da mesma transação da ação. Vínculos criados pelo motor na importação **não** entram (não são ação de ninguém); ações anteriores à rodada 23 não têm registro.
## API ## API
@ -72,11 +74,12 @@ Todas com `PermissaoApp("utilitarios", "conciliacao-fornecedores")`.
| Endpoint | Método | Uso | | Endpoint | Método | Uso |
|---|---|---| |---|---|---|
| `/api/conciliacoes-fornecedores/` | GET/POST | histórico (`?codigo_empresa=` filtra) / criação multipart (só `codigo_empresa` + `arquivo`; data-base e tolerâncias são definidas no servidor) | | `/api/conciliacoes-fornecedores/` | GET/POST | histórico (`?codigo_empresa=` filtra) / criação multipart (só `codigo_empresa` + `arquivo`; data-base e tolerâncias são definidas no servidor) |
| `/api/conciliacoes-fornecedores/{id}/` | GET/DELETE | detalhe completo (contas + lançamentos + vínculos + análise, ~1 MB para 2.500 lançamentos) / exclusão (apaga o arquivo depois do commit) | | `/api/conciliacoes-fornecedores/{id}/` | GET/DELETE | detalhe completo (contas + lançamentos + vínculos + análise, ~1 MB para 2.500 lançamentos; reaplica a validação automática e regrava o `resumo` se mudou) / exclusão (apaga o arquivo depois do commit) |
| `/api/conciliacoes-fornecedores/nome-empresa/?codigo_empresa=` | GET | nome no Questor (`null` se não achou ou Questor fora) | | `/api/conciliacoes-fornecedores/nome-empresa/?codigo_empresa=` | GET | nome no Questor (`null` se não achou ou Questor fora) |
| `/api/conciliacoes-fornecedores/{id}/exportar-xlsx/` | GET | planilha com abas Resumo, Pendências e Alertas | | `/api/conciliacoes-fornecedores/{id}/exportar-xlsx/` | GET | planilha com abas Resumo, Pendências e Alertas |
| `/api/conciliacoes-fornecedores-contas/{id}/vincular/` | POST | `{lancamentos: [ids]}`: vínculo manual; exige itens sem vínculo, da própria conta, com pelo menos um débito e um crédito. Créditos maiores que débitos **além da tolerância** da conciliação viram `tipo="parcial"` (pagamento parcial: a `diferenca` continua em aberto, entra na distribuição como um título com a data do crédito mais antigo do vínculo e conta para o aging); dentro da tolerância, ou pago a maior, `tipo="manual"` (fecha, com alerta de diferença) | | `/api/conciliacoes-fornecedores-contas/{id}/vincular/` | POST | `{lancamentos: [ids]}`: vínculo manual; exige itens sem vínculo, da própria conta, com pelo menos um débito e um crédito. Créditos maiores que débitos **além da tolerância** da conciliação viram `tipo="parcial"` (pagamento parcial: a `diferenca` continua em aberto, entra na distribuição como um título com a data do crédito mais antigo do vínculo e conta para o aging); dentro da tolerância, ou pago a maior, `tipo="manual"` (fecha, com alerta de diferença) |
| `/api/conciliacoes-fornecedores-contas/{id}/validar/` | POST | `{validada, observacao}` | | `/api/conciliacoes-fornecedores-contas/{id}/validar/` | POST | `{validada, observacao}` |
| `/api/conciliacoes-fornecedores-contas/{id}/validar-vencidos/` | POST | `{validada, observacao?}`: validação e observação dos títulos em aberto há mais de 3 meses do fornecedor (aba própria), independentes de `validar/` |
| `/api/conciliacoes-fornecedores-contas/{id}/historico-vinculos/` | GET | histórico das ações manuais sobre vínculos da conta, mais recente primeiro (botão "i" do razão) | | `/api/conciliacoes-fornecedores-contas/{id}/historico-vinculos/` | GET | histórico das ações manuais sobre vínculos da conta, mais recente primeiro (botão "i" do razão) |
| `/api/conciliacoes-fornecedores-vinculos/{id}/` | DELETE | desfaz o vínculo | | `/api/conciliacoes-fornecedores-vinculos/{id}/` | DELETE | desfaz o vínculo |
| `/api/conciliacoes-fornecedores-vinculos/{id}/adicionar/` | POST | `{lancamentos: [ids]}` (1 ou mais): acrescenta lançamentos pendentes da mesma conta a um vínculo **manual ou parcial** (automático é 400), sem desfazer. Tipo e diferença recalculados por `_conciliacao_tipo_vinculo_manual()`, a mesma regra da criação (pode continuar parcial, fechar ou virar pago a maior); `criado_por` passa a ser quem acrescentou | | `/api/conciliacoes-fornecedores-vinculos/{id}/adicionar/` | POST | `{lancamentos: [ids]}` (1 ou mais): acrescenta lançamentos pendentes da mesma conta a um vínculo **manual ou parcial** (automático é 400), sem desfazer. Tipo e diferença recalculados por `_conciliacao_tipo_vinculo_manual()`, a mesma regra da criação (pode continuar parcial, fechar ou virar pago a maior); `criado_por` passa a ser quem acrescentou |
@ -87,9 +90,10 @@ Os três endpoints de ajuste devolvem `{conta, resumo}`: a conta reanalisada e o
Duas vistas na mesma página. **Histórico**: formulário de nova conciliação (o código da empresa busca o nome no Questor com debounce; o nome do arquivo com prefixo numérico sugere o código) + tabela do histórico. **Detalhe** (`#conciliacao-<id>` na URL), desenhado para ser leve (pedido do usuário, validado antes num protótipo): Duas vistas na mesma página. **Histórico**: formulário de nova conciliação (o código da empresa busca o nome no Questor com debounce; o nome do arquivo com prefixo numérico sugere o código) + tabela do histórico. **Detalhe** (`#conciliacao-<id>` na URL), desenhado para ser leve (pedido do usuário, validado antes num protótipo):
- cabeçalho da empresa baixo, código e nome na mesma linha; - cabeçalho da empresa baixo, código e nome na mesma linha;
- **uma faixa de indicadores**: saldo em aberto, em aberto há mais de 3 meses (clica e filtra), pagamentos sem título (divergência, clica e filtra) e uma **barra de progresso da validação** ("146 de 260"); - **uma faixa de indicadores**: saldo em aberto, em aberto há mais de 3 meses (clica e abre a aba própria, com quantos fornecedores já foram validados nela), pagamentos sem título (divergência, clica e filtra) e uma **barra de progresso da validação** ("146 de 260");
- os apontamentos menores (diferença de juros/desconto, pagamento sem título de mesmo valor, conta transitória) como **etiquetas** clicáveis numa linha, só quando existem; - os apontamentos menores (diferença de juros/desconto, pagamento sem título de mesmo valor, conta transitória) como **etiquetas** clicáveis numa linha, só quando existem;
- **os números por situação ficam só nos chips** (Todos/Divergentes/Atenção/Pendentes/Conciliados/Validados), que também filtram: não duplicar em card; - **duas abas** (`.pa-tabs`, `mostrarAba()`): "Conciliação" (tudo abaixo) e **"Em aberto há mais de 3 meses"** (contador de fornecedores ainda a validar): fornecedores com `total_vencido > 0`, chips A validar (padrão)/Validados/Todos, busca, tabela ordenável (conta, fornecedor, quantidade de títulos, mais antigo com dias em aberto, valor, checkbox "Validado"); expandir mostra os títulos antigos (`titulosVencidos()`: lançamentos com `situacao.categoria === "vencido"` mais saldos de pagamento parcial vencidos) com dias em aberto até a data-base, observação e "Marcar como validado". Validado some da lista com fade (mesma ideia de `removerConta()`), e a expansão recolhe. Mesmo visual de linhas apagadas da aba principal;
- **os números por situação ficam só nos chips** (Todos/Divergentes/Atenção/Conciliados/Validados), que também filtram: não duplicar em card;
- **contas validadas escondidas por padrão** ("Mostrar validadas" ao lado da busca; o chip "Validados" mostra sempre), para a lista mostrar o que falta revisar; depois de qualquer ajuste numa conta (validar, vincular, desfazer), `aplicarRespostaConta()` reaplica o filtro (`contaPassaFiltro()`, a mesma regra de `contasFiltradas()`) e, se a conta não couber mais, ela sai na hora com um fade (`removerConta()`), sem esperar o próximo render da lista; - **contas validadas escondidas por padrão** ("Mostrar validadas" ao lado da busca; o chip "Validados" mostra sempre), para a lista mostrar o que falta revisar; depois de qualquer ajuste numa conta (validar, vincular, desfazer), `aplicarRespostaConta()` reaplica o filtro (`contaPassaFiltro()`, a mesma regra de `contasFiltradas()`) e, se a conta não couber mais, ela sai na hora com um fade (`removerConta()`), sem esperar o próximo render da lista;
- filtros e cabeçalho da tabela **fixos ao rolar** (`position: sticky` abaixo da `.topbar`; `.conc-table-wrap` desliga o `overflow:hidden` de `.pa-table-wrap`, que anularia o sticky); - filtros e cabeçalho da tabela **fixos ao rolar** (`position: sticky` abaixo da `.topbar`; `.conc-table-wrap` desliga o `overflow:hidden` de `.pa-table-wrap`, que anularia o sticky);
- linhas compactas, com o fornecedor e o primeiro alerta cortados com reticências e a situação só como etiqueta. - linhas compactas, com o fornecedor e o primeiro alerta cortados com reticências e a situação só como etiqueta.

View File

@ -6,7 +6,7 @@ Ferramenta para o contador conciliar as contas de fornecedores de uma empresa. E
- vincula, por valor e data, os pagamentos (débitos) aos títulos (créditos) de cada fornecedor, inclusive vários pagamentos somando um título e parcelas; - vincula, por valor e data, os pagamentos (débitos) aos títulos (créditos) de cada fornecedor, inclusive vários pagamentos somando um título e parcelas;
- mostra, por fornecedor, conta, saldo inicial, saldo residual e a situação (conciliada, pendente de pagamento, atenção, divergente, validada); - mostra, por fornecedor, conta, saldo inicial, saldo residual e a situação (conciliada, pendente de pagamento, atenção, divergente, validada);
- alerta títulos em aberto há mais de 2 meses, pagamentos sem título (divergência), diferenças pequenas (possível juros/desconto) e conta transitória com saldo; - separa numa tela própria os títulos em aberto há mais de 3 meses, com validação por fornecedor (não bloqueiam a conciliação); alerta pagamentos sem título (divergência), diferenças pequenas (possível juros/desconto) e conta transitória com saldo;
- deixa o contador desfazer e criar vínculos, registrar observação e validar a conta; - deixa o contador desfazer e criar vínculos, registrar observação e validar a conta;
- guarda o histórico e exporta o resultado em XLSX. - guarda o histórico e exporta o resultado em XLSX.

View File

@ -46,7 +46,6 @@ SEVERIDADE_MEDIA = "media"
SEVERIDADE_BAIXA = "baixa" SEVERIDADE_BAIXA = "baixa"
STATUS_CONCILIADA = "conciliada" STATUS_CONCILIADA = "conciliada"
STATUS_PENDENTE = "pendente"
STATUS_ATENCAO = "atencao" STATUS_ATENCAO = "atencao"
STATUS_DIVERGENTE = "divergente" STATUS_DIVERGENTE = "divergente"
@ -424,15 +423,10 @@ def analisa_conta(
f"{quantidade} pagamento(s) sem título em aberto correspondente, somando {_moeda(total_excedente)}.", f"{quantidade} pagamento(s) sem título em aberto correspondente, somando {_moeda(total_excedente)}.",
) )
) )
if total_vencido > 0: # Título em aberto há mais de `MESES_VENCIDO` meses não é alerta da
quantidade = sum(1 for s in situacoes.values() if s.categoria == CATEGORIA_VENCIDO) # conciliação (pedido do usuário: "não é bloqueio para análise"): fica em
alertas.append( # `total_vencido` e na categoria `vencido` de cada título, revisado numa
AlertaConta( # tela à parte, com validação própria por fornecedor.
"credito_vencido",
SEVERIDADE_MEDIA,
f"{quantidade} título(s) em aberto há mais de {MESES_VENCIDO} meses, somando {_moeda(total_vencido)}.",
)
)
if total_nao_vinculado > 0: if total_nao_vinculado > 0:
quantidade = sum(1 for s in situacoes.values() if s.categoria == CATEGORIA_NAO_VINCULADO) quantidade = sum(1 for s in situacoes.values() if s.categoria == CATEGORIA_NAO_VINCULADO)
alertas.append( alertas.append(
@ -453,13 +447,14 @@ def analisa_conta(
AlertaConta("diferenca_vinculo", SEVERIDADE_BAIXA, f"{origem} com diferença de {_moeda(vinculo.diferenca)}: {natureza}.") AlertaConta("diferenca_vinculo", SEVERIDADE_BAIXA, f"{origem} com diferença de {_moeda(vinculo.diferenca)}: {natureza}.")
) )
# Só títulos em aberto, sem pagamento a conciliar, é conta conciliada
# (pedido do usuário; antes era "Pendente de pagamento", que deixou de
# existir): o que falta ali é o pagamento acontecer, não uma análise.
severidades = {a.severidade for a in alertas} severidades = {a.severidade for a in alertas}
if SEVERIDADE_ALTA in severidades: if SEVERIDADE_ALTA in severidades:
status = STATUS_DIVERGENTE status = STATUS_DIVERGENTE
elif severidades: elif severidades:
status = STATUS_ATENCAO status = STATUS_ATENCAO
elif total_aberto > 0:
status = STATUS_PENDENTE
else: else:
status = STATUS_CONCILIADA status = STATUS_CONCILIADA

View File

@ -38,6 +38,9 @@ class ContaXlsx:
situacao: str situacao: str
validada: bool validada: bool
observacao: str observacao: str
# revisão dos títulos em aberto há mais de 3 meses (tela à parte)
vencidos_validados: bool = False
vencidos_observacao: str = ""
@dataclass @dataclass
@ -114,6 +117,8 @@ def gera_xlsx(
"Situação", "Situação",
"Validada", "Validada",
"Observação", "Observação",
"Títulos > 3 meses validados",
"Observação (títulos > 3 meses)",
] ]
linha = _cabecalho(ws, "Conciliação de Fornecedores - Resumo", empresa, subtitulo, colunas) linha = _cabecalho(ws, "Conciliação de Fornecedores - Resumo", empresa, subtitulo, colunas)
for conta in contas: for conta in contas:
@ -129,13 +134,15 @@ def gera_xlsx(
conta.situacao, conta.situacao,
"Sim" if conta.validada else "Não", "Sim" if conta.validada else "Não",
conta.observacao, conta.observacao,
("Sim" if conta.vencidos_validados else "Não") if conta.total_vencido else "",
conta.vencidos_observacao,
] ]
for coluna, valor in enumerate(valores, start=1): for coluna, valor in enumerate(valores, start=1):
celula = ws.cell(row=linha, column=coluna, value=valor) celula = ws.cell(row=linha, column=coluna, value=valor)
if 4 <= coluna <= 8: if 4 <= coluna <= 8:
celula.number_format = FORMATO_MOEDA celula.number_format = FORMATO_MOEDA
linha += 1 linha += 1
_larguras(ws, [12, 18, 45, 16, 16, 16, 18, 18, 20, 10, 50]) _larguras(ws, [12, 18, 45, 16, 16, 16, 18, 18, 20, 10, 50, 14, 50])
ws = wb.create_sheet("Pendências") ws = wb.create_sheet("Pendências")
colunas = ["Conta", "Fornecedor", "Data", "Seqüência", "Histórico", "Natureza", "Valor", "Valor em aberto", "Situação"] colunas = ["Conta", "Fornecedor", "Data", "Seqüência", "Histórico", "Natureza", "Valor", "Valor em aberto", "Situação"]

View File

@ -0,0 +1,35 @@
# Generated by Django 6.0.7 on 2026-09-28 19:27
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0085_conciliacao_vinculo_sequencia'),
]
operations = [
migrations.AddField(
model_name='conciliacaofornecedorconta',
name='vencidos_observacao',
field=models.TextField(blank=True, verbose_name='Observação sobre os títulos antigos'),
),
migrations.AddField(
model_name='conciliacaofornecedorconta',
name='vencidos_validados',
field=models.BooleanField(default=False, verbose_name='Títulos antigos validados'),
),
migrations.AddField(
model_name='conciliacaofornecedorconta',
name='vencidos_validados_em',
field=models.DateTimeField(blank=True, null=True, verbose_name='Títulos antigos validados em'),
),
migrations.AddField(
model_name='conciliacaofornecedorconta',
name='vencidos_validados_por',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -2750,6 +2750,13 @@ class ConciliacaoFornecedorConta(models.Model):
validada_por = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="+") validada_por = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
validada_em = models.DateTimeField("Validada em", null=True, blank=True) validada_em = models.DateTimeField("Validada em", null=True, blank=True)
observacao = models.TextField("Observação do contador", blank=True) observacao = models.TextField("Observação do contador", blank=True)
# Revisão dos títulos em aberto há mais de 3 meses (tela à parte, pedido
# do usuário): validação própria por fornecedor, independente da
# validação da conciliação acima.
vencidos_validados = models.BooleanField("Títulos antigos validados", default=False)
vencidos_validados_por = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
vencidos_validados_em = models.DateTimeField("Títulos antigos validados em", null=True, blank=True)
vencidos_observacao = models.TextField("Observação sobre os títulos antigos", blank=True)
class Meta: class Meta:
verbose_name = "Conta (Conciliação de fornecedores)" verbose_name = "Conta (Conciliação de fornecedores)"

View File

@ -5253,7 +5253,6 @@ class IndicadorContabilDefinicaoViewSet(viewsets.ModelViewSet):
_CONCILIACAO_PERMISSAO = ("utilitarios", "conciliacao-fornecedores") _CONCILIACAO_PERMISSAO = ("utilitarios", "conciliacao-fornecedores")
_CONCILIACAO_ROTULO_STATUS = { _CONCILIACAO_ROTULO_STATUS = {
conciliacao_alertas.STATUS_CONCILIADA: "Conciliada", conciliacao_alertas.STATUS_CONCILIADA: "Conciliada",
conciliacao_alertas.STATUS_PENDENTE: "Pendente de pagamento",
conciliacao_alertas.STATUS_ATENCAO: "Atenção", conciliacao_alertas.STATUS_ATENCAO: "Atenção",
conciliacao_alertas.STATUS_DIVERGENTE: "Divergente", conciliacao_alertas.STATUS_DIVERGENTE: "Divergente",
} }
@ -5368,6 +5367,11 @@ def _conciliacao_conta_payload(
"validada_automatica": conta.validada and conta.validada_por_id is None, "validada_automatica": conta.validada and conta.validada_por_id is None,
"validada_em": conta.validada_em.isoformat() if conta.validada_em else None, "validada_em": conta.validada_em.isoformat() if conta.validada_em else None,
"observacao": conta.observacao, "observacao": conta.observacao,
"vencidos_validados": conta.vencidos_validados,
"vencidos_validados_por_nome": conta.vencidos_validados_por.nome if conta.vencidos_validados_por else None,
"vencidos_validados_em": conta.vencidos_validados_em.isoformat() if conta.vencidos_validados_em else None,
"vencidos_observacao": conta.vencidos_observacao,
"qtd_vencidos": sum(1 for s in analise.situacoes.values() if s.categoria == conciliacao_alertas.CATEGORIA_VENCIDO),
"status": analise.status, "status": analise.status,
"status_rotulo": _CONCILIACAO_ROTULO_STATUS[analise.status], "status_rotulo": _CONCILIACAO_ROTULO_STATUS[analise.status],
"transitoria": conciliacao_alertas.eh_conta_transitoria(conta.fornecedor), "transitoria": conciliacao_alertas.eh_conta_transitoria(conta.fornecedor),
@ -5416,7 +5420,7 @@ def _conciliacao_carrega(
"""Contas + lançamentos + vínculos + análise, com 3 consultas no total """Contas + lançamentos + vínculos + análise, com 3 consultas no total
(não uma por conta).""" (não uma por conta)."""
if contas is None: if contas is None:
contas = list(conciliacao.contas.select_related("validada_por").order_by("ordem", "id")) contas = list(conciliacao.contas.select_related("validada_por", "vencidos_validados_por").order_by("ordem", "id"))
ids = [c.id for c in contas] ids = [c.id for c in contas]
lancs_por_conta: dict[int, list[ConciliacaoFornecedorLancamento]] = {i: [] for i in ids} lancs_por_conta: dict[int, list[ConciliacaoFornecedorLancamento]] = {i: [] for i in ids}
for lanc in ConciliacaoFornecedorLancamento.objects.filter(conta_id__in=ids).order_by("ordem", "id"): for lanc in ConciliacaoFornecedorLancamento.objects.filter(conta_id__in=ids).order_by("ordem", "id"):
@ -5438,11 +5442,14 @@ def _conciliacao_carrega(
def _conciliacao_calcula_resumo(carregado: list[_ConciliacaoContaCarregada]) -> dict[str, Any]: def _conciliacao_calcula_resumo(carregado: list[_ConciliacaoContaCarregada]) -> dict[str, Any]:
por_status = {chave: 0 for chave in _CONCILIACAO_ROTULO_STATUS} por_status = {chave: 0 for chave in _CONCILIACAO_ROTULO_STATUS}
total_aberto = total_vencido = total_excedente = total_nao_vinculado = Decimal("0") total_aberto = total_vencido = total_excedente = total_nao_vinculado = Decimal("0")
validadas = 0 validadas = contas_vencidas = contas_vencidas_validadas = 0
contas_por_alerta = {"credito_vencido": 0, "diferenca_vinculo": 0, "pagamento_sem_titulo": 0, "conta_transitoria": 0} contas_por_alerta = {"diferenca_vinculo": 0, "pagamento_sem_titulo": 0, "conta_transitoria": 0}
for conta, _, _, analise in carregado: for conta, _, _, analise in carregado:
por_status[analise.status] += 1 por_status[analise.status] += 1
validadas += conta.validada validadas += conta.validada
if analise.total_vencido > 0:
contas_vencidas += 1
contas_vencidas_validadas += conta.vencidos_validados
total_aberto += analise.total_em_aberto total_aberto += analise.total_em_aberto
total_vencido += analise.total_vencido total_vencido += analise.total_vencido
total_excedente += analise.total_excedente total_excedente += analise.total_excedente
@ -5458,7 +5465,8 @@ def _conciliacao_calcula_resumo(carregado: list[_ConciliacaoContaCarregada]) ->
"total_vencido": str(total_vencido), "total_vencido": str(total_vencido),
"total_excedente": str(total_excedente), "total_excedente": str(total_excedente),
"total_nao_vinculado": str(total_nao_vinculado), "total_nao_vinculado": str(total_nao_vinculado),
"contas_vencidas": contas_por_alerta["credito_vencido"], "contas_vencidas": contas_vencidas,
"contas_vencidas_validadas": contas_vencidas_validadas,
"contas_com_diferenca": contas_por_alerta["diferenca_vinculo"], "contas_com_diferenca": contas_por_alerta["diferenca_vinculo"],
"contas_pagamento_sem_titulo": contas_por_alerta["pagamento_sem_titulo"], "contas_pagamento_sem_titulo": contas_por_alerta["pagamento_sem_titulo"],
"contas_transitorias": contas_por_alerta["conta_transitoria"], "contas_transitorias": contas_por_alerta["conta_transitoria"],
@ -5490,7 +5498,7 @@ def _conciliacao_resposta_conta(conta: ConciliacaoFornecedorConta, sincroniza_va
vincular/desfazer, reaplica a validação automática; depois de o vincular/desfazer, reaplica a validação automática; depois de o
contador marcar/desmarcar à mão (`sincroniza_validacao=False`), não.""" contador marcar/desmarcar à mão (`sincroniza_validacao=False`), não."""
conciliacao = conta.conciliacao conciliacao = conta.conciliacao
conta = ConciliacaoFornecedorConta.objects.select_related("validada_por").get(pk=conta.pk) conta = ConciliacaoFornecedorConta.objects.select_related("validada_por", "vencidos_validados_por").get(pk=conta.pk)
[carregada] = _conciliacao_carrega(conciliacao, [conta]) [carregada] = _conciliacao_carrega(conciliacao, [conta])
if sincroniza_validacao and _conciliacao_sincroniza_validacao_automatica(conta, carregada[3]): if sincroniza_validacao and _conciliacao_sincroniza_validacao_automatica(conta, carregada[3]):
conta.save(update_fields=["validada", "validada_por", "validada_em"]) conta.save(update_fields=["validada", "validada_por", "validada_em"])
@ -5590,6 +5598,8 @@ def _conciliacao_gera_xlsx(conciliacao: ConciliacaoFornecedor) -> bytes:
situacao=_CONCILIACAO_ROTULO_STATUS[analise.status], situacao=_CONCILIACAO_ROTULO_STATUS[analise.status],
validada=conta.validada, validada=conta.validada,
observacao=conta.observacao, observacao=conta.observacao,
vencidos_validados=conta.vencidos_validados,
vencidos_observacao=conta.vencidos_observacao,
) )
) )
for vinc in vincs: for vinc in vincs:
@ -5672,8 +5682,19 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Response: def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Response:
conciliacao = self.get_object() conciliacao = self.get_object()
carregado = _conciliacao_carrega(conciliacao) carregado = _conciliacao_carrega(conciliacao)
# A regra de situação pode mudar depois da importação (ex.: "só
# títulos em aberto" passou a ser conciliada): ao abrir, reaplica a
# validação automática, como na criação (pedido do usuário). A feita
# por uma pessoa nunca é mexida.
alteradas = [conta for conta, _, _, analise in carregado if _conciliacao_sincroniza_validacao_automatica(conta, analise)]
resumo = _conciliacao_calcula_resumo(carregado)
if alteradas:
ConciliacaoFornecedorConta.objects.bulk_update(alteradas, ["validada", "validada_por", "validada_em"])
if alteradas or conciliacao.resumo != resumo:
conciliacao.resumo = resumo
conciliacao.save(update_fields=["resumo"])
dados = dict(ConciliacaoFornecedorListSerializer(conciliacao).data) dados = dict(ConciliacaoFornecedorListSerializer(conciliacao).data)
dados["resumo"] = _conciliacao_calcula_resumo(carregado) dados["resumo"] = resumo
dados["contas"] = [_conciliacao_conta_payload(*item) for item in carregado] dados["contas"] = [_conciliacao_conta_payload(*item) for item in carregado]
return Response(dados) return Response(dados)
@ -5799,7 +5820,7 @@ class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet):
"""Ajustes manuais numa conta: vincular lançamentos pendentes e marcar """Ajustes manuais numa conta: vincular lançamentos pendentes e marcar
a conta como validada (com observação).""" a conta como validada (com observação)."""
queryset = ConciliacaoFornecedorConta.objects.select_related("conciliacao", "validada_por") queryset = ConciliacaoFornecedorConta.objects.select_related("conciliacao", "validada_por", "vencidos_validados_por")
def get_permissions(self) -> list[BasePermission]: def get_permissions(self) -> list[BasePermission]:
return [PermissaoApp(*_CONCILIACAO_PERMISSAO)] return [PermissaoApp(*_CONCILIACAO_PERMISSAO)]
@ -5877,6 +5898,23 @@ class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet):
conta.save(update_fields=["validada", "validada_por", "validada_em", "observacao"]) conta.save(update_fields=["validada", "validada_por", "validada_em", "observacao"])
return _conciliacao_resposta_conta(conta, sincroniza_validacao=False) return _conciliacao_resposta_conta(conta, sincroniza_validacao=False)
@action(detail=True, methods=["post"], url_path="validar-vencidos")
def validar_vencidos(self, request: Request, pk: str | None = None) -> Response:
"""Validação dos títulos em aberto há mais de 3 meses do fornecedor
(tela à parte), independente da validação da conciliação. Mesmo
corpo de `validar/`: `{validada, observacao?}`."""
conta = self.get_object()
entrada = ConciliacaoFornecedorValidarSerializer(data=request.data)
entrada.is_valid(raise_exception=True)
dados = entrada.validated_data
conta.vencidos_validados = dados["validada"]
conta.vencidos_validados_por = request.user if conta.vencidos_validados else None
conta.vencidos_validados_em = timezone.now() if conta.vencidos_validados else None
if "observacao" in dados:
conta.vencidos_observacao = dados["observacao"].strip()
conta.save(update_fields=["vencidos_validados", "vencidos_validados_por", "vencidos_validados_em", "vencidos_observacao"])
return _conciliacao_resposta_conta(conta, sincroniza_validacao=False)
class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet): class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet):
"""`DELETE` desfaz o vínculo: os lançamentos voltam a pendente. """`DELETE` desfaz o vínculo: os lançamentos voltam a pendente.

View File

@ -413,15 +413,36 @@ button.conc-kpi.is-active {
hover, pra `.pa-table tr:hover td` continuar dando o feedback de sempre. hover, pra `.pa-table tr:hover td` continuar dando o feedback de sempre.
O conteúdo também fica apagado: o fundo vai no `tr` e a opacidade no `td` O conteúdo também fica apagado: o fundo vai no `tr` e a opacidade no `td`
(com fundo transparente), senão a opacidade clarearia o fundo junto. */ (com fundo transparente), senão a opacidade clarearia o fundo junto. */
#conc-contas-body:has(> tr.conc-row.is-aberta) > tr.conc-row:not(.is-aberta):not(:hover) { :is(#conc-contas-body, #conc-venc-body):has(> tr.conc-row.is-aberta) > tr.conc-row:not(.is-aberta):not(:hover) {
background: var(--bg-canvas); background: var(--bg-canvas);
} }
#conc-contas-body:has(> tr.conc-row.is-aberta) > tr.conc-row:not(.is-aberta):not(:hover) > td { :is(#conc-contas-body, #conc-venc-body):has(> tr.conc-row.is-aberta) > tr.conc-row:not(.is-aberta):not(:hover) > td {
background: transparent; background: transparent;
opacity: 0.35; opacity: 0.35;
} }
/* ---------------- Abas do detalhe ---------------- */
.conc-abas {
margin-bottom: var(--space-3);
}
.conc-abas .conc-chip__count {
margin-left: 4px;
}
.conc-venc-intro {
margin: var(--space-2) 0 var(--space-3);
font-size: 0.8rem;
color: var(--text-muted);
}
.conc-venc-dias {
font-size: 0.74rem;
color: var(--text-muted);
}
.conc-expand-btn { .conc-expand-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@ -13,15 +13,18 @@
const PID_CONC_MOEDA = new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }); const PID_CONC_MOEDA = new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" });
// "Pendente de pagamento" deixou de existir: só títulos em aberto, sem
// pagamento a conciliar, é conciliada (pedido do usuário).
const PID_CONC_STATUS = { const PID_CONC_STATUS = {
divergente: { rotulo: "Divergente", pill: "status-pill--danger", rank: 0 }, divergente: { rotulo: "Divergente", pill: "status-pill--danger", rank: 0 },
atencao: { rotulo: "Atenção", pill: "status-pill--warning", rank: 1 }, atencao: { rotulo: "Atenção", pill: "status-pill--warning", rank: 1 },
pendente: { rotulo: "Pendente de pagamento", pill: "status-pill--neutral", rank: 2 }, conciliada: { rotulo: "Conciliada", pill: "status-pill--ok", rank: 2 },
conciliada: { rotulo: "Conciliada", pill: "status-pill--ok", rank: 3 },
}; };
const PID_CONC_CHEVRON =
'<span class="conc-expand-btn"><svg class="conc-expand-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>';
const PID_CONC_ROTULO_ALERTA = { const PID_CONC_ROTULO_ALERTA = {
credito_vencido: "Títulos em aberto há mais de 3 meses",
debito_excedente: "Pagamentos sem título em aberto", debito_excedente: "Pagamentos sem título em aberto",
pagamento_sem_titulo: "Pagamentos sem título de mesmo valor", pagamento_sem_titulo: "Pagamentos sem título de mesmo valor",
diferenca_vinculo: "Diferenças de juros/desconto", diferenca_vinculo: "Diferenças de juros/desconto",
@ -273,7 +276,7 @@ function pidConcTextoValidacao(conta) {
} }
function pidConcStatusHtml(status) { function pidConcStatusHtml(status) {
const info = PID_CONC_STATUS[status] || PID_CONC_STATUS.pendente; const info = PID_CONC_STATUS[status] || PID_CONC_STATUS.conciliada;
return `<span class="status-pill ${info.pill}">${info.rotulo}</span>`; return `<span class="status-pill ${info.pill}">${info.rotulo}</span>`;
} }
@ -531,6 +534,11 @@ document.addEventListener("DOMContentLoaded", async () => {
history.replaceState(null, "", `#conciliacao-${id}`); history.replaceState(null, "", `#conciliacao-${id}`);
abertas.clear(); abertas.clear();
estadoConta.clear(); estadoConta.clear();
vencAbertas.clear();
vencFiltro = "pendentes";
vencBusca.value = "";
detalhe = null; // mostrarAba não redesenha nada até o detalhe novo chegar
mostrarAba("conciliacao");
filtroStatus = ""; filtroStatus = "";
filtroAlerta = ""; filtroAlerta = "";
contasBusca.value = ""; contasBusca.value = "";
@ -575,7 +583,8 @@ document.addEventListener("DOMContentLoaded", async () => {
porStatus[c.status] = (porStatus[c.status] || 0) + 1; porStatus[c.status] = (porStatus[c.status] || 0) + 1;
}); });
const soma = (lista, campo) => lista.reduce((total, c) => total + (Number(c[campo]) || 0), 0); const soma = (lista, campo) => lista.reduce((total, c) => total + (Number(c[campo]) || 0), 0);
const vencidas = contasComAlerta("credito_vencido"); const vencidas = contasVencidas();
const vencidasValidadas = vencidas.filter((c) => c.vencidos_validados).length;
const excedentes = contas.filter((c) => Number(c.total_excedente) > 0); const excedentes = contas.filter((c) => Number(c.total_excedente) > 0);
const validados = contas.filter((c) => c.validada).length; const validados = contas.filter((c) => c.validada).length;
const pct = contas.length ? Math.round((validados / contas.length) * 100) : 0; const pct = contas.length ? Math.round((validados / contas.length) * 100) : 0;
@ -615,7 +624,11 @@ document.addEventListener("DOMContentLoaded", async () => {
"", "",
"" ""
)} )}
${kpi("Em aberto há mais de 3 meses", pidConcMoeda(soma(vencidas, "total_vencido")), `${vencidas.length} fornecedor(es)`, "conc-kpi--warning", "credito_vencido")} <button type="button" class="conc-kpi conc-kpi--warning${abaAtual === "vencidos" ? " is-active" : ""}" data-conc-abrir-vencidos>
<span class="conc-kpi__rotulo">Em aberto há mais de 3 meses</span>
<span class="conc-kpi__valor">${pidConcMoeda(soma(vencidas, "total_vencido"))}</span>
<span class="conc-kpi__detalhe">${vencidas.length} fornecedor(es) · ${vencidasValidadas} validado(s)</span>
</button>
${kpi("Pagamentos sem título", pidConcMoeda(soma(excedentes, "total_excedente")), `${excedentes.length} fornecedor(es) · divergência`, "conc-kpi--danger", "debito_excedente")} ${kpi("Pagamentos sem título", pidConcMoeda(soma(excedentes, "total_excedente")), `${excedentes.length} fornecedor(es) · divergência`, "conc-kpi--danger", "debito_excedente")}
<div class="conc-progresso"> <div class="conc-progresso">
<div class="conc-progresso__topo"><span class="conc-kpi__rotulo">Validação</span><span><strong>${validados}</strong> de ${contas.length} fornecedores</span></div> <div class="conc-progresso__topo"><span class="conc-kpi__rotulo">Validação</span><span><strong>${validados}</strong> de ${contas.length} fornecedores</span></div>
@ -630,10 +643,15 @@ document.addEventListener("DOMContentLoaded", async () => {
const alerta = el.dataset.concAlerta; const alerta = el.dataset.concAlerta;
filtroAlerta = filtroAlerta === alerta ? "" : alerta; filtroAlerta = filtroAlerta === alerta ? "" : alerta;
filtroStatus = ""; filtroStatus = "";
mostrarAba("conciliacao");
renderCards(); renderCards();
renderContas(); renderContas();
}); });
}); });
cardsEl.querySelector("[data-conc-abrir-vencidos]").addEventListener("click", () => {
mostrarAba(abaAtual === "vencidos" ? "conciliacao" : "vencidos");
});
abaVencidosQtd.textContent = vencidas.length - vencidasValidadas;
filtroStatusBtns.forEach((btn) => { filtroStatusBtns.forEach((btn) => {
const status = btn.dataset.concStatus; const status = btn.dataset.concStatus;
@ -733,7 +751,8 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
function resumoAlertas(conta) { function resumoAlertas(conta) {
if (!conta.alertas.length) return conta.qtd_pendentes ? `${conta.qtd_pendentes} lançamento(s) pendente(s)` : ""; // sem alerta, o que sobra pendente são só títulos (pagamento não vinculado é alerta)
if (!conta.alertas.length) return conta.qtd_pendentes ? `${conta.qtd_pendentes} título(s) em aberto, sem pagamento a conciliar` : "";
// só o primeiro alerta, cortado com reticências: o resto está na expansão // só o primeiro alerta, cortado com reticências: o resto está na expansão
return conta.alertas[0].mensagem; return conta.alertas[0].mensagem;
} }
@ -746,7 +765,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const saldoFinal = Number(conta.saldo_final); const saldoFinal = Number(conta.saldo_final);
const vencido = Number(conta.total_vencido); const vencido = Number(conta.total_vencido);
tr.innerHTML = ` tr.innerHTML = `
<td><span class="conc-expand-btn"><svg class="conc-expand-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg></span></td> <td>${PID_CONC_CHEVRON}</td>
<td class="conc-conta-numero">${pidConcEscape(conta.conta_numero)}</td> <td class="conc-conta-numero">${pidConcEscape(conta.conta_numero)}</td>
<td><span class="conc-fornecedor__nome">${pidConcEscape(conta.fornecedor)}</span><span class="conc-fornecedor__alerta">${pidConcEscape(resumoAlertas(conta))}</span></td> <td><span class="conc-fornecedor__nome">${pidConcEscape(conta.fornecedor)}</span><span class="conc-fornecedor__alerta">${pidConcEscape(resumoAlertas(conta))}</span></td>
<td class="conc-num conc-valor${Number(conta.saldo_inicial) === 0 ? " conc-valor--zero" : Number(conta.saldo_inicial) < 0 ? " conc-valor--devedor" : ""}">${pidConcSaldo(conta.saldo_inicial)}</td> <td class="conc-num conc-valor${Number(conta.saldo_inicial) === 0 ? " conc-valor--zero" : Number(conta.saldo_inicial) < 0 ? " conc-valor--devedor" : ""}">${pidConcSaldo(conta.saldo_inicial)}</td>
@ -1281,6 +1300,281 @@ document.addEventListener("DOMContentLoaded", async () => {
return painel; return painel;
} }
// ======================================================================
// Tela "Em aberto há mais de 3 meses"
// ======================================================================
// Pedido do usuário: título antigo sem pagamento não é bloqueio para a
// análise (não deixa mais a conta em "Atenção"), mas precisa de revisão
// própria. Esta aba lista os fornecedores com esses títulos, com
// validação e observação por fornecedor (`validar-vencidos/`), separadas
// da validação da conciliação.
const abaBtns = Array.from(document.querySelectorAll("[data-conc-aba]"));
const abaPaineis = Array.from(document.querySelectorAll("[data-conc-painel]"));
const abaVencidosQtd = document.getElementById("conc-aba-vencidos-qtd");
const vencBody = document.getElementById("conc-venc-body");
const vencVazia = document.getElementById("conc-venc-vazia");
const vencBusca = document.getElementById("conc-venc-busca");
const vencFiltroBtns = Array.from(document.querySelectorAll("[data-conc-venc]"));
const vencSortHeaders = Array.from(document.querySelectorAll("#conc-venc-table th[data-venc-sort]"));
let abaAtual = "conciliacao";
let vencFiltro = "pendentes";
let vencSortKey = "total_vencido";
let vencSortDir = -1;
const vencAbertas = new Set();
function mostrarAba(aba) {
abaAtual = aba;
abaBtns.forEach((btn) => btn.classList.toggle("is-active", btn.dataset.concAba === aba));
abaPaineis.forEach((painel) => { painel.hidden = painel.dataset.concPainel !== aba; });
if (!detalhe) return;
renderCards();
if (aba === "vencidos") renderVencidos();
else renderContas();
}
abaBtns.forEach((btn) => btn.addEventListener("click", () => mostrarAba(btn.dataset.concAba)));
function contasVencidas() {
return detalhe.contas.filter((c) => Number(c.total_vencido) > 0);
}
/** Títulos da conta em aberto há mais de 3 meses, do mais antigo ao mais
* recente: lançamentos sem vínculo e saldos de pagamento parcial. */
function titulosVencidos(conta) {
const itens = conta.lancamentos
.filter((l) => l.situacao && l.situacao.categoria === "vencido")
.map((l) => ({ lanc: l, valor: l.situacao.valor_em_aberto }));
conta.vinculos.forEach((v) => {
if (!v.situacao || v.situacao.categoria !== "vencido") return;
const titulo = conta.lancamentos
.filter((l) => l.vinculo_id === v.id && l.natureza === "C")
.sort((a, b) => a.data.localeCompare(b.data))[0];
if (titulo) {
itens.push({ lanc: { ...titulo, historico: `${titulo.historico} (saldo do pagamento parcial)` }, valor: v.situacao.valor_em_aberto });
}
});
return itens.sort((a, b) => a.lanc.data.localeCompare(b.lanc.data));
}
function diasEmAberto(iso) {
const dias = Math.round((Date.parse(detalhe.data_base) - Date.parse(iso.slice(0, 10))) / 86400000);
return `${dias} dias`;
}
function textoValidacaoVencidos(conta) {
if (!conta.vencidos_validados) return "";
return `Validado por ${conta.vencidos_validados_por_nome || "—"} em ${pidConcDataHora(conta.vencidos_validados_em)}`;
}
function vencPassaFiltro(c) {
if (Number(c.total_vencido) <= 0) return false;
if (vencFiltro === "pendentes" && c.vencidos_validados) return false;
if (vencFiltro === "validados" && !c.vencidos_validados) return false;
const termo = vencBusca.value.trim().toLowerCase();
return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo);
}
const vencSortValor = {
conta_numero: (c) => Number(c.conta_numero),
fornecedor: (c) => c.fornecedor,
qtd_vencidos: (c) => c.qtd_vencidos,
mais_antigo: (c) => (titulosVencidos(c)[0] || { lanc: { data: "" } }).lanc.data,
total_vencido: (c) => Number(c.total_vencido),
vencidos_validados: (c) => (c.vencidos_validados ? 1 : 0),
};
vencSortHeaders.forEach((th) => {
th.addEventListener("click", () => {
const chave = th.dataset.vencSort;
vencSortDir = vencSortKey === chave ? vencSortDir * -1 : 1;
vencSortKey = chave;
renderVencidos();
});
});
vencFiltroBtns.forEach((btn) => {
btn.addEventListener("click", () => {
vencFiltro = btn.dataset.concVenc;
renderVencidos();
});
});
vencBusca.addEventListener("input", renderVencidos);
function atualizarContagemVencidos() {
const todas = contasVencidas();
const validadas = todas.filter((c) => c.vencidos_validados).length;
const contagem = { pendentes: todas.length - validadas, validados: validadas, "": todas.length };
vencFiltroBtns.forEach((btn) => {
const chave = btn.dataset.concVenc;
btn.innerHTML = `${btn.textContent.replace(/\s*\d+$/, "")} <span class="conc-chip__count">${contagem[chave]}</span>`;
btn.classList.toggle("is-active", vencFiltro === chave);
});
vencSortHeaders.forEach((h) => {
h.classList.toggle("is-sort-asc", h.dataset.vencSort === vencSortKey && vencSortDir === 1);
h.classList.toggle("is-sort-desc", h.dataset.vencSort === vencSortKey && vencSortDir === -1);
});
return todas.length;
}
function mensagemVencidosVazia(totalComVencidos) {
if (!totalComVencidos) return "Nenhum título em aberto há mais de 3 meses nesta conciliação.";
if (vencFiltro === "pendentes" && !vencBusca.value.trim()) return "Todos os fornecedores com títulos antigos já foram validados.";
return "Nenhum fornecedor para este filtro.";
}
function renderVencidos() {
const total = atualizarContagemVencidos();
const extrator = vencSortValor[vencSortKey];
const lista = detalhe.contas
.filter(vencPassaFiltro)
.sort((a, b) => pidConcCompara(extrator(a), extrator(b)) * vencSortDir);
vencBody.innerHTML = "";
vencVazia.hidden = lista.length > 0;
vencVazia.textContent = mensagemVencidosVazia(total);
const fragmento = document.createDocumentFragment();
lista.forEach((conta) => montarLinhasVencidos(conta).forEach((tr) => fragmento.appendChild(tr)));
vencBody.appendChild(fragmento);
}
function montarLinhasVencidos(conta) {
const aberta = vencAbertas.has(conta.id);
const titulos = titulosVencidos(conta);
const maisAntigo = titulos[0] ? titulos[0].lanc.data : null;
const tr = document.createElement("tr");
tr.className = `conc-row${aberta ? " is-aberta" : ""}`;
tr.dataset.vencContaId = conta.id;
const subtitulo = textoValidacaoVencidos(conta) || conta.vencidos_observacao || "";
tr.innerHTML = `
<td>${PID_CONC_CHEVRON}</td>
<td class="conc-conta-numero">${pidConcEscape(conta.conta_numero)}</td>
<td><span class="conc-fornecedor__nome">${pidConcEscape(conta.fornecedor)}</span><span class="conc-fornecedor__alerta">${pidConcEscape(subtitulo)}</span></td>
<td class="conc-num">${titulos.length}</td>
<td>${pidConcData(maisAntigo)}${maisAntigo ? ` <span class="conc-venc-dias">· ${diasEmAberto(maisAntigo)}</span>` : ""}</td>
<td class="conc-num conc-valor conc-valor--vencido">${pidConcMoeda(conta.total_vencido)}</td>
<td class="conc-validado-col"><input type="checkbox" class="conc-validado-check" data-venc-check ${conta.vencidos_validados ? "checked" : ""} aria-label="Confirmar que os títulos antigos do fornecedor foram validados" /></td>`;
const check = tr.querySelector("[data-venc-check]");
check.addEventListener("click", (evento) => evento.stopPropagation());
check.addEventListener("change", async () => {
check.disabled = true;
try {
await salvarVencidos(conta, check.checked);
} catch (erro) {
check.checked = !check.checked;
check.disabled = false;
pidAlert(erro.message, { titulo: "Não foi possível salvar" });
}
});
tr.addEventListener("click", () => {
if (vencAbertas.has(conta.id)) vencAbertas.delete(conta.id);
else vencAbertas.add(conta.id);
substituirVencidos(conta);
});
if (!aberta) return [tr];
const trDetalhe = document.createElement("tr");
trDetalhe.className = "conc-detail-row";
trDetalhe.dataset.vencDetalheId = conta.id;
const td = document.createElement("td");
td.colSpan = 7;
td.className = "conc-detail-cell";
td.appendChild(montarDetalheVencidos(conta, titulos));
trDetalhe.appendChild(td);
return [tr, trDetalhe];
}
function montarDetalheVencidos(conta, titulos) {
const painel = document.createElement("div");
painel.className = "conc-detail";
painel.innerHTML = `
<div>
<div class="conc-detail__secao-titulo"><h4>Títulos em aberto há mais de 3 meses (${titulos.length})</h4></div>
<table class="conc-subtabela">
<thead><tr><th>Data</th><th>Seq.</th><th>Histórico</th><th class="conc-num">Valor em aberto</th><th class="conc-num">Em aberto há</th></tr></thead>
<tbody>${titulos
.map(
(t) => `
<tr>
<td>${pidConcData(t.lanc.data)}</td>
<td>${t.lanc.eh_saldo_anterior ? "—" : pidConcEscape(t.lanc.sequencia || "—")}</td>
${pidConcHistoricoHtml(t.lanc)}
<td class="conc-num conc-valor">${pidConcMoeda(t.valor)}</td>
<td class="conc-num">${diasEmAberto(t.lanc.data)}</td>
</tr>`
)
.join("")}</tbody>
</table>
</div>
<div class="conc-validacao">
<label class="conc-detail__secao-titulo"><h4>Observação sobre os títulos antigos</h4></label>
<textarea data-venc-observacao placeholder="Registre a análise (ex.: títulos já pagos, aguardando baixa no Questor).">${pidConcEscape(conta.vencidos_observacao)}</textarea>
<div class="conc-detail__acoes">
<button type="button" class="btn-outline conc-btn-sm" data-venc-salvar-obs>Salvar observação</button>
<button type="button" class="${conta.vencidos_validados ? "btn-outline" : "btn-solid"} conc-btn-sm" data-venc-validar>${conta.vencidos_validados ? "Remover validação" : "Marcar como validado"}</button>
${conta.vencidos_validados ? `<span class="conc-validacao__info">${pidConcEscape(textoValidacaoVencidos(conta))}</span>` : ""}
<button type="button" class="btn-outline conc-btn-sm conc-recolher-btn" data-venc-recolher>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 15l6-6 6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
Recolher
</button>
</div>
</div>`;
const observacaoEl = painel.querySelector("[data-venc-observacao]");
const salvar = async (validada) => {
try {
await salvarVencidos(conta, validada, observacaoEl.value);
} catch (erro) {
pidAlert(erro.message, { titulo: "Não foi possível salvar" });
}
};
painel.querySelector("[data-venc-salvar-obs]").addEventListener("click", () => salvar(conta.vencidos_validados));
painel.querySelector("[data-venc-validar]").addEventListener("click", () => salvar(!conta.vencidos_validados));
painel.querySelector("[data-venc-recolher]").addEventListener("click", () => {
vencAbertas.delete(conta.id);
substituirVencidos(conta);
const linha = vencBody.querySelector(`tr[data-venc-conta-id="${conta.id}"]`);
if (linha && linha.getBoundingClientRect().top < 120) linha.scrollIntoView({ block: "center" });
});
return painel;
}
function substituirVencidos(conta) {
const antiga = vencBody.querySelector(`tr[data-venc-conta-id="${conta.id}"]`);
if (!antiga) return;
const detalheAntigo = vencBody.querySelector(`tr[data-venc-detalhe-id="${conta.id}"]`);
antiga.replaceWith(...montarLinhasVencidos(conta));
if (detalheAntigo) detalheAntigo.remove();
}
function removerVencidos(contaId) {
const linhas = vencBody.querySelectorAll(`tr[data-venc-conta-id="${contaId}"], tr[data-venc-detalhe-id="${contaId}"]`);
linhas.forEach((tr) => tr.classList.add("is-saindo"));
window.setTimeout(() => {
linhas.forEach((tr) => tr.remove());
if (!vencBody.querySelector("tr[data-venc-conta-id]")) {
vencVazia.hidden = false;
vencVazia.textContent = mensagemVencidosVazia(contasVencidas().length);
}
}, 160);
}
/** Grava a validação/observação dos títulos antigos e atualiza a linha
* (sai da lista com fade se deixar de caber no filtro, como na conciliação). */
async function salvarVencidos(conta, validada, observacao) {
const corpo = { validada };
if (observacao !== undefined) corpo.observacao = observacao;
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar-vencidos/`, {
method: "POST",
body: corpo,
});
const indice = detalhe.contas.findIndex((c) => c.id === resposta.conta.id);
const eraValidada = indice >= 0 && detalhe.contas[indice].vencidos_validados;
if (indice >= 0) detalhe.contas[indice] = resposta.conta;
if (!eraValidada && resposta.conta.vencidos_validados) vencAbertas.delete(resposta.conta.id);
detalhe.resumo = resposta.resumo;
renderCards();
atualizarContagemVencidos();
if (vencPassaFiltro(resposta.conta)) substituirVencidos(resposta.conta);
else removerVencidos(resposta.conta.id);
}
// ====================================================================== // ======================================================================
// Início // Início
// ====================================================================== // ======================================================================

View File

@ -416,12 +416,19 @@
<div class="conc-cards" id="conc-cards"></div> <div class="conc-cards" id="conc-cards"></div>
<div class="conc-alertas-gerais" id="conc-alertas-gerais"></div> <div class="conc-alertas-gerais" id="conc-alertas-gerais"></div>
<!-- Duas telas no detalhe: a conciliação e a revisão dos títulos
em aberto há mais de 3 meses (validação própria por fornecedor) -->
<div class="pa-tabs conc-abas" id="conc-abas">
<button type="button" class="pa-tab is-active" data-conc-aba="conciliacao">Conciliação</button>
<button type="button" class="pa-tab" data-conc-aba="vencidos">Em aberto há mais de 3 meses <span class="conc-chip__count" id="conc-aba-vencidos-qtd">0</span></button>
</div>
<div class="pa-tab-panel" data-conc-painel="conciliacao">
<div class="conc-toolbar conc-toolbar--fixa"> <div class="conc-toolbar conc-toolbar--fixa">
<div class="conc-chip-row" id="conc-filtro-status"> <div class="conc-chip-row" id="conc-filtro-status">
<button type="button" class="conc-chip is-active" data-conc-status="">Todos</button> <button type="button" class="conc-chip is-active" data-conc-status="">Todos</button>
<button type="button" class="conc-chip" data-conc-status="divergente">Divergentes</button> <button type="button" class="conc-chip" data-conc-status="divergente">Divergentes</button>
<button type="button" class="conc-chip" data-conc-status="atencao">Atenção</button> <button type="button" class="conc-chip" data-conc-status="atencao">Atenção</button>
<button type="button" class="conc-chip" data-conc-status="pendente">Pendentes de pagamento</button>
<button type="button" class="conc-chip" data-conc-status="conciliada">Conciliados</button> <button type="button" class="conc-chip" data-conc-status="conciliada">Conciliados</button>
<button type="button" class="conc-chip" data-conc-status="validada">Validados</button> <button type="button" class="conc-chip" data-conc-status="validada">Validados</button>
</div> </div>
@ -451,6 +458,40 @@
</table> </table>
</div> </div>
<p class="conc-empty" id="conc-contas-vazia" hidden>Nenhum fornecedor para este filtro.</p> <p class="conc-empty" id="conc-contas-vazia" hidden>Nenhum fornecedor para este filtro.</p>
</div>
<div class="pa-tab-panel" data-conc-painel="vencidos" hidden>
<div class="conc-toolbar conc-toolbar--fixa">
<div class="conc-chip-row" id="conc-venc-filtro">
<button type="button" class="conc-chip is-active" data-conc-venc="pendentes">A validar</button>
<button type="button" class="conc-chip" data-conc-venc="validados">Validados</button>
<button type="button" class="conc-chip" data-conc-venc="">Todos</button>
</div>
<div class="pa-search">
<input type="search" id="conc-venc-busca" placeholder="Buscar fornecedor ou conta..." autocomplete="off" />
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5" stroke-linecap="round"/></svg>
</div>
</div>
<p class="conc-venc-intro">Títulos sem pagamento vinculado há mais de 3 meses até a data-base. Não bloqueiam a conciliação: a validação desta tela é separada da validação do fornecedor.</p>
<div class="pa-table-wrap conc-table-wrap">
<table class="pa-table conc-table" id="conc-venc-table">
<thead>
<tr>
<th class="conc-expand-col"></th>
<th data-venc-sort="conta_numero">Conta <span class="conc-sort-icon">↕</span></th>
<th data-venc-sort="fornecedor">Fornecedor <span class="conc-sort-icon">↕</span></th>
<th class="conc-num" data-venc-sort="qtd_vencidos">Títulos <span class="conc-sort-icon">↕</span></th>
<th data-venc-sort="mais_antigo">Mais antigo <span class="conc-sort-icon">↕</span></th>
<th class="conc-num" data-venc-sort="total_vencido">Valor &gt; 3 meses <span class="conc-sort-icon">↕</span></th>
<th class="conc-validado-col" data-venc-sort="vencidos_validados">Validado <span class="conc-sort-icon">↕</span></th>
</tr>
</thead>
<tbody id="conc-venc-body"></tbody>
</table>
</div>
<p class="conc-empty" id="conc-venc-vazia" hidden></p>
</div>
</section> </section>
</div> </div>
</main> </main>