diff --git a/CLAUDE.md b/CLAUDE.md index 9863249..6a1aee9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -345,7 +345,7 @@ Ver `portal_api/nao_conformidades/CLAUDE.md`. ## 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`. diff --git a/portal_api/conciliacao_fornecedores/CHANGELOG.md b/portal_api/conciliacao_fornecedores/CHANGELOG.md index b58c17e..a712b3c 100644 --- a/portal_api/conciliacao_fornecedores/CHANGELOG.md +++ b/portal_api/conciliacao_fornecedores/CHANGELOG.md @@ -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) 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. diff --git a/portal_api/conciliacao_fornecedores/CLAUDE.md b/portal_api/conciliacao_fornecedores/CLAUDE.md index 45b8906..9114aea 100644 --- a/portal_api/conciliacao_fornecedores/CLAUDE.md +++ b/portal_api/conciliacao_fornecedores/CLAUDE.md @@ -48,22 +48,24 @@ As sugestões saem de uma **distribuição** dos pagamentos não vinculados sobr | Categoria | Significado (fato) | Alerta | |---|---|---| -| `em_aberto` | título sem pagamento vinculado (ou saldo de um pagamento parcial vinculado) | nenhum (conta fica "Pendente de pagamento") | -| `vencido` | idem, há mais de 3 meses até a data-base | média (`credito_vencido`, valor real) | +| `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 | **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") | | `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". -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`) -`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 @@ -72,11 +74,12 @@ Todas com `PermissaoApp("utilitarios", "conciliacao-fornecedores")`. | 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/{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/{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}/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-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 | @@ -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-` 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; -- **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 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; - 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. diff --git a/portal_api/conciliacao_fornecedores/README.md b/portal_api/conciliacao_fornecedores/README.md index cdb5288..8a41d7b 100644 --- a/portal_api/conciliacao_fornecedores/README.md +++ b/portal_api/conciliacao_fornecedores/README.md @@ -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; - 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; - guarda o histórico e exporta o resultado em XLSX. diff --git a/portal_api/conciliacao_fornecedores/alertas.py b/portal_api/conciliacao_fornecedores/alertas.py index 30cd24a..9043490 100644 --- a/portal_api/conciliacao_fornecedores/alertas.py +++ b/portal_api/conciliacao_fornecedores/alertas.py @@ -46,7 +46,6 @@ SEVERIDADE_MEDIA = "media" SEVERIDADE_BAIXA = "baixa" STATUS_CONCILIADA = "conciliada" -STATUS_PENDENTE = "pendente" STATUS_ATENCAO = "atencao" STATUS_DIVERGENTE = "divergente" @@ -424,15 +423,10 @@ def analisa_conta( f"{quantidade} pagamento(s) sem título em aberto correspondente, somando {_moeda(total_excedente)}.", ) ) - if total_vencido > 0: - quantidade = sum(1 for s in situacoes.values() if s.categoria == CATEGORIA_VENCIDO) - alertas.append( - AlertaConta( - "credito_vencido", - SEVERIDADE_MEDIA, - f"{quantidade} título(s) em aberto há mais de {MESES_VENCIDO} meses, somando {_moeda(total_vencido)}.", - ) - ) + # Título em aberto há mais de `MESES_VENCIDO` meses não é alerta da + # conciliação (pedido do usuário: "não é bloqueio para análise"): fica em + # `total_vencido` e na categoria `vencido` de cada título, revisado numa + # tela à parte, com validação própria por fornecedor. if total_nao_vinculado > 0: quantidade = sum(1 for s in situacoes.values() if s.categoria == CATEGORIA_NAO_VINCULADO) alertas.append( @@ -453,13 +447,14 @@ def analisa_conta( 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} if SEVERIDADE_ALTA in severidades: status = STATUS_DIVERGENTE elif severidades: status = STATUS_ATENCAO - elif total_aberto > 0: - status = STATUS_PENDENTE else: status = STATUS_CONCILIADA diff --git a/portal_api/conciliacao_fornecedores/exportacao.py b/portal_api/conciliacao_fornecedores/exportacao.py index 8cd45e7..418d51c 100644 --- a/portal_api/conciliacao_fornecedores/exportacao.py +++ b/portal_api/conciliacao_fornecedores/exportacao.py @@ -38,6 +38,9 @@ class ContaXlsx: situacao: str validada: bool observacao: str + # revisão dos títulos em aberto há mais de 3 meses (tela à parte) + vencidos_validados: bool = False + vencidos_observacao: str = "" @dataclass @@ -114,6 +117,8 @@ def gera_xlsx( "Situação", "Validada", "Observação", + "Títulos > 3 meses validados", + "Observação (títulos > 3 meses)", ] linha = _cabecalho(ws, "Conciliação de Fornecedores - Resumo", empresa, subtitulo, colunas) for conta in contas: @@ -129,13 +134,15 @@ def gera_xlsx( conta.situacao, "Sim" if conta.validada else "Não", 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): celula = ws.cell(row=linha, column=coluna, value=valor) if 4 <= coluna <= 8: celula.number_format = FORMATO_MOEDA 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") colunas = ["Conta", "Fornecedor", "Data", "Seqüência", "Histórico", "Natureza", "Valor", "Valor em aberto", "Situação"] diff --git a/portal_api/migrations/0086_conciliacao_vencidos_validados.py b/portal_api/migrations/0086_conciliacao_vencidos_validados.py new file mode 100644 index 0000000..1ac5a4b --- /dev/null +++ b/portal_api/migrations/0086_conciliacao_vencidos_validados.py @@ -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), + ), + ] diff --git a/portal_api/models.py b/portal_api/models.py index 9e7ac78..337898c 100644 --- a/portal_api/models.py +++ b/portal_api/models.py @@ -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_em = models.DateTimeField("Validada em", null=True, 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: verbose_name = "Conta (Conciliação de fornecedores)" diff --git a/portal_api/views.py b/portal_api/views.py index 7fdd221..e71fb8d 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -5253,7 +5253,6 @@ class IndicadorContabilDefinicaoViewSet(viewsets.ModelViewSet): _CONCILIACAO_PERMISSAO = ("utilitarios", "conciliacao-fornecedores") _CONCILIACAO_ROTULO_STATUS = { conciliacao_alertas.STATUS_CONCILIADA: "Conciliada", - conciliacao_alertas.STATUS_PENDENTE: "Pendente de pagamento", conciliacao_alertas.STATUS_ATENCAO: "Atenção", 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_em": conta.validada_em.isoformat() if conta.validada_em else None, "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_rotulo": _CONCILIACAO_ROTULO_STATUS[analise.status], "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 (não uma por conta).""" 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] 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"): @@ -5438,11 +5442,14 @@ def _conciliacao_carrega( def _conciliacao_calcula_resumo(carregado: list[_ConciliacaoContaCarregada]) -> dict[str, Any]: por_status = {chave: 0 for chave in _CONCILIACAO_ROTULO_STATUS} total_aberto = total_vencido = total_excedente = total_nao_vinculado = Decimal("0") - validadas = 0 - contas_por_alerta = {"credito_vencido": 0, "diferenca_vinculo": 0, "pagamento_sem_titulo": 0, "conta_transitoria": 0} + validadas = contas_vencidas = contas_vencidas_validadas = 0 + contas_por_alerta = {"diferenca_vinculo": 0, "pagamento_sem_titulo": 0, "conta_transitoria": 0} for conta, _, _, analise in carregado: por_status[analise.status] += 1 validadas += conta.validada + if analise.total_vencido > 0: + contas_vencidas += 1 + contas_vencidas_validadas += conta.vencidos_validados total_aberto += analise.total_em_aberto total_vencido += analise.total_vencido total_excedente += analise.total_excedente @@ -5458,7 +5465,8 @@ def _conciliacao_calcula_resumo(carregado: list[_ConciliacaoContaCarregada]) -> "total_vencido": str(total_vencido), "total_excedente": str(total_excedente), "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_pagamento_sem_titulo": contas_por_alerta["pagamento_sem_titulo"], "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 contador marcar/desmarcar à mão (`sincroniza_validacao=False`), não.""" 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]) if sincroniza_validacao and _conciliacao_sincroniza_validacao_automatica(conta, carregada[3]): 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], validada=conta.validada, observacao=conta.observacao, + vencidos_validados=conta.vencidos_validados, + vencidos_observacao=conta.vencidos_observacao, ) ) for vinc in vincs: @@ -5672,8 +5682,19 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet): def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Response: conciliacao = self.get_object() 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["resumo"] = _conciliacao_calcula_resumo(carregado) + dados["resumo"] = resumo dados["contas"] = [_conciliacao_conta_payload(*item) for item in carregado] return Response(dados) @@ -5799,7 +5820,7 @@ class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet): """Ajustes manuais numa conta: vincular lançamentos pendentes e marcar 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]: return [PermissaoApp(*_CONCILIACAO_PERMISSAO)] @@ -5877,6 +5898,23 @@ class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet): conta.save(update_fields=["validada", "validada_por", "validada_em", "observacao"]) 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): """`DELETE` desfaz o vínculo: os lançamentos voltam a pendente. diff --git a/static/css/conciliacao-fornecedores.css b/static/css/conciliacao-fornecedores.css index 55d4061..103629b 100644 --- a/static/css/conciliacao-fornecedores.css +++ b/static/css/conciliacao-fornecedores.css @@ -413,15 +413,36 @@ button.conc-kpi.is-active { 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` (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); } -#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; 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 { display: inline-flex; align-items: center; diff --git a/static/js/conciliacao-fornecedores.js b/static/js/conciliacao-fornecedores.js index 9bbb68d..b98111a 100644 --- a/static/js/conciliacao-fornecedores.js +++ b/static/js/conciliacao-fornecedores.js @@ -13,15 +13,18 @@ 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 = { divergente: { rotulo: "Divergente", pill: "status-pill--danger", rank: 0 }, 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: 3 }, + conciliada: { rotulo: "Conciliada", pill: "status-pill--ok", rank: 2 }, }; +const PID_CONC_CHEVRON = + ''; + const PID_CONC_ROTULO_ALERTA = { - credito_vencido: "Títulos em aberto há mais de 3 meses", debito_excedente: "Pagamentos sem título em aberto", pagamento_sem_titulo: "Pagamentos sem título de mesmo valor", diferenca_vinculo: "Diferenças de juros/desconto", @@ -273,7 +276,7 @@ function pidConcTextoValidacao(conta) { } function pidConcStatusHtml(status) { - const info = PID_CONC_STATUS[status] || PID_CONC_STATUS.pendente; + const info = PID_CONC_STATUS[status] || PID_CONC_STATUS.conciliada; return `${info.rotulo}`; } @@ -531,6 +534,11 @@ document.addEventListener("DOMContentLoaded", async () => { history.replaceState(null, "", `#conciliacao-${id}`); abertas.clear(); estadoConta.clear(); + vencAbertas.clear(); + vencFiltro = "pendentes"; + vencBusca.value = ""; + detalhe = null; // mostrarAba não redesenha nada até o detalhe novo chegar + mostrarAba("conciliacao"); filtroStatus = ""; filtroAlerta = ""; contasBusca.value = ""; @@ -575,7 +583,8 @@ document.addEventListener("DOMContentLoaded", async () => { porStatus[c.status] = (porStatus[c.status] || 0) + 1; }); 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 validados = contas.filter((c) => c.validada).length; 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")} + ${kpi("Pagamentos sem título", pidConcMoeda(soma(excedentes, "total_excedente")), `${excedentes.length} fornecedor(es) · divergência`, "conc-kpi--danger", "debito_excedente")}
Validação${validados} de ${contas.length} fornecedores
@@ -630,10 +643,15 @@ document.addEventListener("DOMContentLoaded", async () => { const alerta = el.dataset.concAlerta; filtroAlerta = filtroAlerta === alerta ? "" : alerta; filtroStatus = ""; + mostrarAba("conciliacao"); renderCards(); renderContas(); }); }); + cardsEl.querySelector("[data-conc-abrir-vencidos]").addEventListener("click", () => { + mostrarAba(abaAtual === "vencidos" ? "conciliacao" : "vencidos"); + }); + abaVencidosQtd.textContent = vencidas.length - vencidasValidadas; filtroStatusBtns.forEach((btn) => { const status = btn.dataset.concStatus; @@ -733,7 +751,8 @@ document.addEventListener("DOMContentLoaded", async () => { } 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 return conta.alertas[0].mensagem; } @@ -746,7 +765,7 @@ document.addEventListener("DOMContentLoaded", async () => { const saldoFinal = Number(conta.saldo_final); const vencido = Number(conta.total_vencido); tr.innerHTML = ` - + ${PID_CONC_CHEVRON} ${pidConcEscape(conta.conta_numero)} ${pidConcEscape(conta.fornecedor)}${pidConcEscape(resumoAlertas(conta))} ${pidConcSaldo(conta.saldo_inicial)} @@ -1281,6 +1300,281 @@ document.addEventListener("DOMContentLoaded", async () => { 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+$/, "")} ${contagem[chave]}`; + 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 = ` + ${PID_CONC_CHEVRON} + ${pidConcEscape(conta.conta_numero)} + ${pidConcEscape(conta.fornecedor)}${pidConcEscape(subtitulo)} + ${titulos.length} + ${pidConcData(maisAntigo)}${maisAntigo ? ` · ${diasEmAberto(maisAntigo)}` : ""} + ${pidConcMoeda(conta.total_vencido)} + `; + 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 = ` +
+

Títulos em aberto há mais de 3 meses (${titulos.length})

+ + + ${titulos + .map( + (t) => ` + + + + ${pidConcHistoricoHtml(t.lanc)} + + + ` + ) + .join("")} +
DataSeq.HistóricoValor em abertoEm aberto há
${pidConcData(t.lanc.data)}${t.lanc.eh_saldo_anterior ? "—" : pidConcEscape(t.lanc.sequencia || "—")}${pidConcMoeda(t.valor)}${diasEmAberto(t.lanc.data)}
+
+
+ + +
+ + + ${conta.vencidos_validados ? `${pidConcEscape(textoValidacaoVencidos(conta))}` : ""} + +
+
`; + 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 // ====================================================================== diff --git a/templates/conciliacao-fornecedores.html b/templates/conciliacao-fornecedores.html index 112a8a1..8ac0e5a 100644 --- a/templates/conciliacao-fornecedores.html +++ b/templates/conciliacao-fornecedores.html @@ -416,12 +416,19 @@
+ +
+ + +
+ +
-
@@ -451,6 +458,40 @@
+
+ +