diff --git a/.gitignore b/.gitignore index 97e55c6..1630ba0 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ desktop.ini # Configuração local do Claude Code .claude/settings.local.json + +# Regras gerais de UI (uso local) e documentos de revisão gerados a partir delas +AGENT.MD +docs/revisao-interface-*.docx diff --git a/CLAUDE.md b/CLAUDE.md index 2994d45..9863249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,6 +162,8 @@ Pacotes Python puros (sem ORM), um por ferramenta — cada um com seu próprio ` `pidApiRequest` (em `api.js`) detecta `body instanceof FormData` e, nesse caso, **não** faz `JSON.stringify` nem define `Content-Type` manualmente — deixa o browser montar o `multipart/form-data` com o boundary certo. Usado pelo upload de `icone` em Links & Ferramentas e pelos dois arquivos anexados em "Nova Importação" de Plano de Saúde; todo o resto da API é JSON puro. A única resposta binária da API (`/importacoes-plano-saude/{id}/gerar/`, que devolve CSV/ZIP) não passa por `pidApiRequest` — usa um `fetch` manual dedicado (ver seção "Importação de Plano de Saúde"). +**Escape de HTML obrigatório (`pidEscapeHtml`, `api.js`)**: todo dado vindo da API, do usuário ou de arquivo anexado que entra em HTML montado por template string (`innerHTML`, `insertAdjacentHTML`) passa por `pidEscapeHtml(texto)`, que trata `& < > " '` e serve para conteúdo **e** atributo. Sem isso, um texto com HTML (título de compromisso, nome de link, observação, dado do arquivo da operadora) vira código executado no navegador de quem abre a tela, inclusive de outros usuários (XSS armazenado). Corrigido em todo o frontend na revisão de interface de 2026-09-25 (ver `plano.md`). Regras: escapar o **dado**, num único ponto (sem escape duplo), nunca o HTML que o próprio código monta (ícones, fragmentos); dado atribuído por `.textContent`/`.value` já é seguro e não se escapa; **texto rico sanitizado no servidor com `nh3`** (Ajuda, observações de Acessos Gerais, Resumo do Fechamento) é HTML intencional e **não** se escapa. As funções locais de escape que ainda existem (`pidDcEscapeHtml`, `pidNcfEscapeHtml`, `pidIndEscapeHtml`, `pidConcEscape`, `escapeHtml`/`escapeAttr` do Plano de Saúde) delegam a ela; a versão antiga por `textContent`/`innerHTML` não escapava aspas e não deve voltar a ser usada. + `pidApplyAccessVisibility` não recalcula mais união de permissões no cliente — usa `me.permissoes_efetivas`, já unida no backend (`permissoes_efetivas()` em `views.py`). ## Páginas diff --git a/plano.md b/plano.md index c23a283..e334026 100644 --- a/plano.md +++ b/plano.md @@ -478,6 +478,61 @@ a doc dela junto. Na prática quase nunca traz: o código de todas elas vive em Editar os models ou as views do Relatório Contábil não dispara o `CLAUDE.md` dele. +### 150. Escape de HTML em todo o frontend (XSS) (2026-09-25) + +Primeira correção da revisão de interface feita com a skill `web-design-guidelines` +(relatório em `docs/revisao-interface-2026-09-25.docx`, item 1.1; versão +explicada em `docs/revisao-interface-2026-09-25-resumo.docx`). O usuário pediu +para aplicar **só este item** por enquanto. + +**Problema**: em várias telas, dado vindo da API, do usuário ou de arquivo +anexado entrava em `innerHTML` sem escape, ou com uma função de escape que não +tratava aspas e era usada dentro de atributo. Um texto com HTML virava código +executado no navegador de quem abria a tela. Nos casos de dado compartilhado +(compromisso com visibilidade "todos" no sino e no widget, links, ramais), no +navegador de **outros usuários** (XSS armazenado). + +**Correção**: + +- Função compartilhada `pidEscapeHtml(texto)` em `static/js/api.js` (carregado + em todos os 15 shells, antes dos outros scripts), tratando `& < > " '`, para + conteúdo e atributo. +- As funções locais passaram a delegar a ela: `pidDcEscapeHtml`/`dcEscapeAttr` + (Relatório Contábil), `pidNcfEscapeHtml` (Não Conformidades), + `pidIndEscapeHtml`/`pidIndEscapeAttr` (Indicador), `pidConcEscape`/ + `pidConcEscapeAttr` (Conciliação), `escapeHtml`/`escapeAttr` (Plano de Saúde). + As três primeiras e `pidConcEscape` eram baseadas em `textContent` e deixavam + `"` passar (observação do contador em `aria-label`, tipo de ocorrência em + `value`, nome de quem validou em `title`). +- Escape aplicado nas interpolações sem nenhum escape: `notifications.js` + (título/dono do compromisso, escapados uma vez na criação do objeto), + `widgets.js`, `dual-select.js`, `favorites.js`, `links-ferramentas.js`, + `acessos-gerais.js`, `calendar-individual.js`, `ramais-lookup.js`, + `ramais.js`, `profiles.js`, `users-admin.js`, `importacao-plano-saude.js` + (auditoria com nome/CPF/detalhe do arquivo da operadora, histórico, abas, + alterações), `nao-conformidades.js` (códigos), `indicador-desempenho.js` e + `dashboard-contabil.js` (rótulos com valor de reserva vindo da API, chaves de + indicador personalizado). `custo-contratacao.js`, `ajuda-aplicacao.js`, + `account.js`, `seasonal-theme.js`, `search.js`, `access.js`, `sidebar.js`, + `auth.js` e `events.js` foram revisados e não tinham ponto inseguro. +- **Não escapado de propósito**: texto rico sanitizado no servidor com `nh3` + (Ajuda, observações de Acessos Gerais, Resumo do Fechamento), HTML montado pelo + próprio código (ícones, fragmentos), dado atribuído por `textContent`/`value`, + mensagens de `pidConfirm`/`pidAlert` (o modal usa `textContent`) e ids + numéricos. `href` com `javascript:` não é risco porque `LinkFerramenta.url` e + `AcessoGeral.url` são `URLField` (só http/https/ftp). +- Única mudança visível: onde um campo vinha vazio (`null`), a célula mostrava + "undefined" em alguns pontos do Plano de Saúde e agora fica vazia. + +**Validação**: só frontend, sem banco, API ou migration, e não exige reiniciar o +`runserver`. Sintaxe dos 17 arquivos alterados conferida com um analisador de +JavaScript (esprima, instalado só na pasta de rascunho da sessão). **Não +testado no navegador** (sem renderização neste ambiente). Roteiro sugerido: +cadastrar textos como `Teste negrito "aspas" & cia` em compromisso, link, +ramal, perfil, acesso e observação, e conferir que aparecem **literalmente** em +todas as telas onde são exibidos, sem `&` sobrando. A regra ficou registrada +no `CLAUDE.md` ("Frontend consumindo a API"). + ## Limitações conhecidas / decisões assumidas - Calendário De Paula (empresa) não tem mais versão interna — depende diff --git a/portal_api/admin.py b/portal_api/admin.py index 11ab154..6c9c434 100644 --- a/portal_api/admin.py +++ b/portal_api/admin.py @@ -10,6 +10,7 @@ from .models import ( ConciliacaoFornecedorConta, ConciliacaoFornecedorLancamento, ConciliacaoFornecedorVinculo, + ConciliacaoFornecedorVinculoHistorico, ContabilAchado, ContabilApuracao, ContabilApuracaoReprocessamento, @@ -334,6 +335,12 @@ class ConciliacaoFornecedorVinculoAdmin(admin.ModelAdmin): list_filter = ("tipo",) +@admin.register(ConciliacaoFornecedorVinculoHistorico) +class ConciliacaoFornecedorVinculoHistoricoAdmin(admin.ModelAdmin): + list_display = ("conta", "acao", "tipo_rotulo", "usuario", "criado_em") + list_filter = ("acao",) + + @admin.register(ConciliacaoFornecedorLancamento) class ConciliacaoFornecedorLancamentoAdmin(admin.ModelAdmin): list_display = ("conta", "data", "natureza", "valor", "historico", "vinculo") diff --git a/portal_api/conciliacao_fornecedores/CHANGELOG.md b/portal_api/conciliacao_fornecedores/CHANGELOG.md index 3e283f6..9df4ad6 100644 --- a/portal_api/conciliacao_fornecedores/CHANGELOG.md +++ b/portal_api/conciliacao_fornecedores/CHANGELOG.md @@ -102,3 +102,57 @@ A pedido do usuário, "Natureza" + "Valor" viraram colunas "Débito" + "Crédito ### Rodada 19 — Toggle "Ocultar conciliados" no razão da conta expandida (2026-09-25) Pedido do usuário: um botão para esconder o que já está conciliado no razão desde a primeira pendência, **desmarcado por padrão**. Checkbox "Ocultar conciliados" (estilo `.conc-toggle`, o mesmo de "Mostrar validadas") ao lado do título "Razão desde ... · N não conciliado(s)", com a contagem de linhas vinculadas no período; só aparece quando há alguma. Esconde as `tr.conc-linha--vinculada` pela classe `is-ocultando-conciliados` na tabela; o estado fica por conta (`ocultarConciliados` em `estadoDe`) e é mantido depois de vincular/desfazer. Só frontend (JS/CSS), sem mudança de API. + +### Rodada 20 — Sugestão de parcela com pequena diferença (2026-09-25) + +Pergunta do usuário (ROZINI): por que o pagamento de 14/07 (R$ 2.163,55) era sugerido como parcela do título de 28/05 (R$ 8.654,22) e o de 26/06 (R$ 2.163,57) só como "pode ser pagamento do título"? A regra de parcela aceitava só o arredondamento da divisão (até 1 centavo por parcela no total: 4 × 2.163,57 = 8.654,28, R$ 0,06 acima, limite R$ 0,04). O usuário escolheu, entre aumentar a tolerância e mostrar a diferença, **mostrar a diferença**: `_eh_parcela()` virou `_parcela()`, que devolve a parcela de referência e a diferença; parcela exata continua tendo prioridade, e sem ela vale o pagamento a até 2% (`TOLERANCIA_PERCENTUAL_PADRAO` de `motor.py`, só o percentual) da parcela arredondada mais próxima. Texto novo: "Possível parcela do título de 28/05/2026 (R$ 8.654,22), com R$ 0,01 a mais que a parcela de R$ 2.163,56 (juros/desconto?). Vincule para confirmar." Só sugestão, nenhum valor ou vínculo muda. Testado com os três lançamentos da ROZINI chamando `_analisa_pendentes` direto. + +### Rodada 21 — Linhas do razão com altura fixa (2026-09-25) + +Pedido do usuário: em tela mais estreita, o histórico quebrava em 2 linhas (mais a contrapartida numa terceira) e a sugestão longa também, deixando as linhas do razão altas. Histórico, contrapartida e sugestão passaram a ficar em uma linha cada, com reticências e o texto completo ao passar o mouse (`title`). Colunas Histórico (30%) e Situação (34%) com `max-width: 0`, mesmo truque da coluna de fornecedor, para cederem espaço sem alargar a tabela. Helper novo `pidConcHistoricoHtml()` (usado no razão e nos blocos de vínculo) e `pidConcEscapeAttr()` (o `pidConcEscape` não escapa aspas). Contrapartida ao lado do histórico foi descartada (ajuda a distinguir pagamento de relance). Sem renderização neste ambiente: conferir por print. + +Na mesma rodada, depois do print do usuário: o `title` nativo do navegador não estava no padrão visual do Portal. Trocado por um tooltip próprio (`data-conc-tooltip` + `.conc-tooltip`, mesmo visual de `.dc-hover-tooltip__texto` do Relatório Contábil): um único elemento `position: fixed` no body, delegado no document (`mouseover`/`mouseout`), posicionado acima do texto (abaixo se não couber), escondido ao rolar, e só exibido quando o texto está de fato cortado (`scrollWidth > clientWidth`). Depois de mais um print, o balão ficou apagado sobre o fundo escuro: ganhou borda na cor do tema (`rgba(var(--accent-rgb), 0.55)`) e `--shadow-md` no lugar da borda neutra e `--shadow-sm`. + +### Rodada 22 — Destaque das linhas da sugestão no hover (2026-09-25) + +Pedido do usuário: ao passar o mouse na sugestão, destacar as linhas envolvidas, como já acontece com um vínculo. `SituacaoItem.relacionados` (ids do outro lado da distribuição sugerida, já calculado em `alertas.py`) passou a ir no payload de `situacao` (e no do saldo de vínculo parcial); id negativo é o saldo de um vínculo parcial (`-vinculo.id`) e destaca as linhas daquele vínculo. Na tela, `data-conc-relacionados` na `.conc-sugestao` e a classe `is-sugestao-grupo` na própria linha + relacionadas. Cor dourada (`--gold-rgb`), não o verde do vínculo (sugestão não é vínculo) nem o roxo, que já é o de linha selecionada. + +### Rodada 23 — Histórico de vinculações e desvinculações (2026-09-25) + +Pedido do usuário: saber quando houve vinculação ou desvinculação manual e com quais seq., por um botão "i" no canto. Model novo `ConciliacaoFornecedorVinculoHistorico` (migration `0084_conciliacao_historico_vinculos`, aplicada) gravado em `vincular/`, `adicionar/` e no `DELETE` do vínculo (inclusive desfazer vínculo automático), com usuário, data/hora, tipo e cópia dos lançamentos (o vínculo desfeito é apagado). Endpoint `GET /conciliacoes-fornecedores-contas/{id}/historico-vinculos/`. Na tela, botão "i" à direita de "Vincular selecionados" (tooltip "Histórico de vinculações", o `data-conc-tooltip` ganhou `data-conc-tooltip-sempre` para botão só com ícone) e modal com a lista. Decisões minhas, não confirmadas: histórico **por conta** (não por lançamento); vínculos do motor na importação não entram. Testado pela API com a ROZINI (vincular → acrescentar → desfazer → histórico) dentro de transação desfeita no fim. Sem renderização do modal neste ambiente. + +### Rodada 24 — Sugestão prioriza título de mesmo valor (2026-09-25) + +Pergunta do usuário (ROZINI): depois de desfazer o vínculo automático exato do pagamento de 09/06 (R$ 6.450,57), a sugestão apontava o título de 28/05 (R$ 8.654,22) em vez do título de 09/06 de mesmo valor. Causa: a distribuição das sugestões só tentava parcela e, sem ela, o título mais antigo com saldo; valor exato nem era considerado, e o erro se propagava para as sugestões dos dois títulos. Correção: passada própria de **pares de mesmo valor** antes da ordem por data (desempate do `_pareia_exato` do motor), com a sugestão "Mesmo valor do título de ...". Na mesma rodada, a pedido do usuário, o último recurso (título mais antigo com saldo) ganhou texto que deixa claro ser sugestão fraca: "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ...". Testado chamando `_analisa_pendentes` com os lançamentos do print. + +### Rodada 25 — Histórico e contrapartida no histórico de vinculações (2026-09-25) + +Pedido do usuário, entre quatro melhorias sugeridas para o modal (escolheu só esta): cada lançamento do histórico de vinculações mostra o histórico e a contrapartida, em uma linha cada com reticências e o tooltip do Portal. Lidos na hora pelo id no `historico-vinculos/` (o lançamento não muda depois da importação), então valem também para os registros já gravados. Grid do item ganhou a coluna; a etiqueta Débito/Crédito deixou de esticar (envolvida num `span`). `.conc-tooltip` subiu para `z-index: 200`, acima do `.modal-overlay`. **Sugeridas e não aplicadas** (não reapresentar como novidade): resultado do vínculo (totais/"resta R$ X"), origem do vínculo desfeito (quem criou e quando) e trocar "Manual · manual" por só "Manual". + +### Rodada 26 — Duplo clique na sugestão seleciona as linhas envolvidas (2026-09-25) + +Pedido do usuário: duplo clique na sugestão seleciona a própria linha e as relacionadas (as mesmas do destaque dourado da rodada 22); outro duplo clique remove a seleção. Regra: se todas do grupo já estão marcadas, desmarca; senão, marca todas. Só entram linhas não vinculadas (o saldo de vínculo parcial, id negativo, fica só no destaque). Usa o `change` de cada checkbox, então soma da barra e "Vincular selecionados" se atualizam sozinhos. Efeito colateral deliberado: clique simples **dentro da sugestão** deixa de marcar/desmarcar a linha (senão o duplo clique marcaria e desmarcaria antes); fora dela, a linha continua clicável. + +### Rodada 27 — Quantidade de parcelas na sugestão (2026-09-25) + +Pedido do usuário: a sugestão de possível parcela passa a dizer em quantas vezes. `_parcela()` devolve também `k` (`(k, referência, diferença)`). Textos: "Possível parcela do título de 01/07/2026 (R$ 1.656,80), em 3x de R$ 552,27. Vincule para confirmar." e, com diferença, "... em 4x de R$ 2.163,56, com R$ 0,01 a mais (juros/desconto?). ...". No exemplo do usuário (AUDIO 5, R$ 1.656,80 / R$ 552,27) o cálculo é 3x, não 4x como ele supôs (3 × 552,27 = 1.656,81). Não indica **qual** parcela é (1ª, 2ª...), porque o razão não traz essa informação. + +### Rodada 28 — Soma e saldo residual quando a sugestão cita vários títulos (2026-09-25) + +Pedido do usuário (pagamento URBANO de R$ 616,82 sugerido contra 10 títulos de frete): quando a sugestão de último recurso cita mais de um título, mostrar a soma e o saldo residual. Texto acrescentado: "Soma: R$ 717,41; saldo residual de R$ 100,59 em aberto." (ou "o pagamento excede em R$ X" quando nem todos os títulos cobrem o pagamento). A soma é do saldo **disponível** de cada título no momento (`disponivel_usado`), que pode ser menor que o valor cheio exibido no rótulo quando um pagamento anterior da distribuição já abateu parte dele. Com um título só, o texto não muda. + +### Rodada 29 — Detalhe do vínculo pelo "i" da linha (2026-09-25) + +Pedido do usuário: um "i" na linha vinculada para ver o que o vínculo automático considerou (as outras linhas do vínculo podem estar longe no razão; o destaque no hover não basta). Botão pequeno ao lado de "Desfazer" abre o modal "Vínculo #N": tipo, automático/manual (com o nome de quem criou, no manual), o critério usado em texto (`PID_CONC_CRITERIO_VINCULO`, um por tipo do motor), os lançamentos (mesma linha do histórico de vinculações, com histórico e contrapartida) e os totais de créditos, débitos e diferença. Só frontend: tudo já vinha no payload da conta. O modal do histórico virou genérico (`pidConcAbreModalInfo(titulo, subtitulo)`), usado pelos dois. + +### Rodada 30 — Diferença de juros/desconto vira sugestão, não vínculo automático (2026-09-25) + +Pedido do usuário (BRASPRESS, título R$ 133,81 × pagamento R$ 127,61 vinculados automaticamente "com diferença"): no caso de juros, não vincular automaticamente, mas sugerir e apontar a possibilidade de juros. A etapa 5 do motor (tolerância) saiu de `concilia()`, que deixou de receber `tolerancia` (e `_conciliacao_grava_contas` também). Em `alertas.py`, passada nova de **valor próximo** depois da de mesmo valor: 1:1 dentro da tolerância da conciliação, menor diferença primeiro; sugestão nos dois lados ("Possível pagamento do título de 11/05/2026 (R$ 133,81), com R$ 6,20 a menos (possível desconto). Vincule para confirmar." / "Possível pagamento em 26/06/2026 (R$ 127,61), ..."). `analisa_conta` recebe a `Tolerancia` da conciliação. Vale para **novas importações**; os 21 vínculos `tolerancia` já gravados (uma conciliação) **não foram alterados**: o usuário decidiu deixá-los e refazer a conciliação com uma nova importação; o texto do critério no modal "Vínculo #N" avisa que o critério só valeu até esta data. Risco conhecido: com o piso fixo de R$ 10, em títulos pequenos (fretes de R$ 40) a sugestão de valor próximo pode aparecer por acaso; se incomodar, usar só o percentual, como na parcela. + +### Rodada 31 — Conta validada sai da lista na hora (2026-09-25) + +Bug relatado pelo usuário: ao marcar "Validado", a conta não ia para os validados na hora, às vezes só depois de recarregar a página. Causa: `aplicarRespostaConta()` só redesenhava a linha no lugar (`substituirConta()`), sem reaplicar o filtro; a conta só sumia no próximo render da lista inteira (trocar chip, buscar, recarregar). A regra do filtro virou `contaPassaFiltro()` (usada por `contasFiltradas()` e depois de cada ajuste); conta que não cabe mais sai com fade de 160ms (`removerConta()`, classe `is-saindo`), com a mensagem de lista vazia quando for a última. Vale para validar/desvalidar e para vincular/desfazer que mude a situação com um chip ativo. + +### Rodada 32 — Quitação dos títulos mais antigos como vínculo automático (2026-09-25) + +Pergunta do usuário (BRASPRESS): o pagamento de R$ 234,40 era exatamente a soma dos 4 títulos mais antigos em aberto (a própria sugestão mostrava "saldo residual de R$ 0,00"), por que não foi vinculado? Causa: a combinação só considera os 12 candidatos mais próximos em data (`MAX_CANDIDATOS_COMBINACAO`), e esses 4 estavam nas posições 16 a 19 entre 80. Proposta aceita pelo usuário: etapa nova no motor, `_quita_mais_antigos()` (`TIPO_SEQUENCIA = "sequencia"`, "Quitação dos títulos mais antigos"; migration `0085_conciliacao_vinculo_sequencia`, aplicada, só as choices), depois da combinação e antes da compensação do segmento fechado: pagamento contra a sequência dos títulos mais antigos em aberto, só com soma exata e 2 títulos ou mais, sem limite de itens. Rodando o motor novo sobre a conciliação 20 (só leitura): 8 vínculos desse tipo, todos com diferença zero (BRASPRESS 234,40 = 4 e 616,82 = 7 títulos; TEX COURIER 468,74 = 11; SUD LOG, JOICEMAR, LEOMAR, SEARA, F N BERLANDA). Vale para novas importações. Critério explicado no modal "Vínculo #N". diff --git a/portal_api/conciliacao_fornecedores/CLAUDE.md b/portal_api/conciliacao_fornecedores/CLAUDE.md index d8dd0f0..3e12ae8 100644 --- a/portal_api/conciliacao_fornecedores/CLAUDE.md +++ b/portal_api/conciliacao_fornecedores/CLAUDE.md @@ -32,8 +32,9 @@ Função pura `concilia(itens, tolerancia) -> list[VinculoSugerido]`, uma conta 1. **Segmentação por saldo zerado**: cada ponto em que o saldo corrente (na ordem do arquivo) volta a zero fecha um segmento, e vínculo nunca atravessa segmento. Isso evita casar o frete de janeiro com um pagamento de mesmo valor de julho. 2. **1:1 por valor exato**, menor distância de datas. 3. **Combinação exata** (vários débitos = um crédito, ou vários créditos, como NF + juros, = um débito): até 6 itens, janela de 120 dias, **só os 12 candidatos mais próximos em data**, teto de 20 mil nós por busca. O limite de candidatos existe porque, numa conta com centenas de lançamentos pequenos (EBAZAR, 456 no arquivo real), uma soma exata entre itens sem relação é quase certa se todos puderem participar. -4. **Compensação do segmento fechado**: o que sobrou num segmento que zerou vira um vínculo `saldo_zerado`. -5. **Tolerância**, só no último segmento (o que está em aberto): 1:1 ou 2 itens com diferença até o limite. Com 3 ou mais itens, combinações "a menos de R$ 10" aparecem por acaso. +4. **Quitação dos títulos mais antigos** (`_quita_mais_antigos`, `tipo="sequencia"`, desde 2026-09-25): cada pagamento pendente, em ordem de data, contra a sequência dos títulos mais antigos em aberto (data até a do pagamento); vira vínculo só se a soma fechar **exatamente**, com 2 títulos ou mais. Sem limite de itens nem de candidatos, porque há uma única sequência possível (não é subset-sum). Motivo: fretes acumulados que a combinação não achava por estarem fora dos 12 mais próximos (BRASPRESS: R$ 234,40 = 4 títulos; R$ 616,82 = 7; TEX COURIER: R$ 468,74 = 11). Roda em todo segmento, inclusive nos fechados, antes da compensação. +5. **Compensação do segmento fechado**: o que sobrou num segmento que zerou vira um vínculo `saldo_zerado`. +5. ~~Tolerância~~ **removida em 2026-09-25** (pedido do usuário: "no caso de juros, não vincular automaticamente, mas sugerir e apontar a possibilidade de juros"). Era 1:1 ou 2 itens com diferença até o limite, só no último segmento. Hoje esse par fica pendente e vira **sugestão** em `alertas.py` (ver "valor próximo" abaixo). `TIPO_TOLERANCIA` continua existindo pelos vínculos gravados antes (21 numa conciliação, na data da mudança); `concilia()` não recebe mais `tolerancia`. Resultado no arquivo real: 532 vínculos exatos, 99 combinações, 20 compensações, 21 com diferença. @@ -43,7 +44,7 @@ Resultado no arquivo real: 532 vínculos exatos, 99 combinações, 20 compensaç **Fato x sugestão: só vínculo muda valor** (pedido do usuário). Um título sem vínculo está em aberto pelo **valor cheio**; um pagamento sem vínculo é "Pagamento não vinculado". O razão não traz parcelamento, vencimento nem NF no pagamento, então qualquer dedução sobre qual título um pagamento paga é **sugestão**: vai em `SituacaoItem.sugestao` (texto à parte), nunca na `descricao` (só fato) nem em `valor_em_aberto`. Na tela, a sugestão aparece numa linha própria abaixo do fato, com a etiqueta "Sugestão", em itálico e cor neutra; no XLSX, depois de "Sugestão:". O contador confirma criando o vínculo manual (inclusive o de pagamento parcial, que aí sim reduz o valor em aberto). -As sugestões saem de uma **distribuição** dos pagamentos não vinculados sobre os títulos em aberto: primeiro o título do qual o pagamento parece ser **parcela** (título ≈ k × pagamento, k de 2 a 24, até 1 centavo por parcela), senão o título mais antigo com saldo. Ela serve só para escrever as sugestões e para achar o **excedente** (pagamentos não vinculados além de todos os títulos em aberto), que é fato e é a divergência. Motivo de existir: o arquivo real tem muitos parcelamentos (NF 1.299,00 em 3 × 433,00; NF 25.325,00 em 5 × 5.065,00), e a sugestão aponta ao contador o título provável. +As sugestões saem de uma **distribuição** dos pagamentos não vinculados sobre os títulos em aberto: antes de tudo, numa passada própria anterior à ordem por data, os **pares de mesmo valor** (título × pagamento, desempate igual ao `_pareia_exato` do motor: data mais próxima, depois pagamento após o título; sugestão "Mesmo valor do título de ..."); em seguida, também em passada própria, os **pares de valor próximo** dentro da tolerância da conciliação (`analisa_conta(..., tolerancia=)`, a mesma do cabeçalho: R$ 10 ou 2%, o maior), 1:1, menor diferença e depois data mais próxima, com a sugestão "Possível pagamento do título de ..., com R$ 6,20 a menos (possível desconto)" / "a mais (possível juros/multa)" dos dois lados; depois o título do qual o pagamento parece ser **parcela** (título ≈ k × pagamento, k de 2 a 24, até 1 centavo por parcela; se nenhum título bater assim, aceita o pagamento a até `TOLERANCIA_PERCENTUAL_PADRAO`% (2%) da parcela arredondada mais próxima, só o percentual, sem o piso fixo de R$ 10, e a sugestão diz a diferença; a sugestão sempre diz a quantidade de parcelas: "em 3x de R$ 552,27" / "em 4x de R$ 2.163,56, com R$ 0,01 a mais (juros/desconto?)"; ver `_parcela()` em `alertas.py`), senão o título mais antigo com saldo (sugestão fraca, e o texto diz isso: "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ..."; com mais de um título, acrescenta "Soma: R$ X; saldo residual de R$ Y em aberto." ou "...; o pagamento excede em R$ Y.", sendo a soma do **saldo ainda disponível** de cada título na distribuição, não do valor cheio). Ela serve só para escrever as sugestões e para achar o **excedente** (pagamentos não vinculados além de todos os títulos em aberto), que é fato e é a divergência. Motivo de existir: o arquivo real tem muitos parcelamentos (NF 1.299,00 em 3 × 433,00; NF 25.325,00 em 5 × 5.065,00), e a sugestão aponta ao contador o título provável. | Categoria | Significado (fato) | Alerta | |---|---|---| @@ -62,7 +63,7 @@ Status: `divergente` (algum alerta alto) > `atencao` (médio/baixo) > `pendente` ## 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). +`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. ## API @@ -76,6 +77,7 @@ Todas com `PermissaoApp("utilitarios", "conciliacao-fornecedores")`. | `/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}/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 | @@ -88,9 +90,13 @@ Duas vistas na mesma página. **Histórico**: formulário de nova conciliação - **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"); - 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; -- **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; +- **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. +- nas sub-tabelas de lançamentos, **histórico, contrapartida e sugestão ficam em uma linha cada**, cortados com reticências e com o texto completo num tooltip no visual do Portal (`[data-conc-tooltip]` + `.conc-tooltip`, único elemento `fixed` no body, só aparece quando o texto está cortado; **nunca `title` nativo**, pedido do usuário) (`pidConcHistoricoHtml()`/`pidConcSituacaoHtml()`; `max-width: 0` + largura em % nas colunas Histórico/Situação), para a altura da linha não variar com a largura da tela. +- **"i" pequeno em cada linha vinculada** do razão (ao lado de "Desfazer"): abre o detalhe do vínculo (`pidConcAbreDetalheVinculo()`, só frontend, com o payload já carregado): tipo, automático/manual, o **critério** que o motor usou (`PID_CONC_CRITERIO_VINCULO`, texto por tipo; se mudar uma regra do `motor.py`, atualizar o texto), os lançamentos com histórico/contrapartida e os totais de créditos/débitos com a diferença. Mesmo modal do histórico (`pidConcAbreModalInfo()`). +- botão **"i"** no canto direito da barra do razão abre o modal "Histórico de vinculações" da conta (`pidConcAbreHistoricoVinculos()`, criado sob demanda no body): cada ação com quem, quando, tipo e os seq. envolvidos, com histórico e contrapartida de cada um (lidos na hora pelo id no endpoint, não gravados na cópia) e os acrescentados marcados, borda verde/roxa/vermelha para vinculou/acrescentou/desfez. +- passar o mouse na **sugestão** destaca em dourado a própria linha e as do outro lado da sugestão (`situacao.relacionados` no payload, id negativo = saldo de vínculo parcial; classe `is-sugestao-grupo`), no mesmo espírito do destaque verde do vínculo. **Duplo clique na sugestão** marca a linha e as relacionadas não vinculadas (pelo `change` dos checkboxes, então soma e "Vincular selecionados" acompanham); se já estavam todas marcadas, desmarca. Por isso o clique simples dentro da sugestão não marca/desmarca a linha (dispararia duas vezes antes do duplo clique). - na conta expandida, a **barra de seleção** (soma de créditos/débitos, diferença e "Vincular selecionados") e o thead da lista de pendentes também ficam fixos, empilhados abaixo do thead da tabela de fornecedores (`--conc-thead-altura`/`--conc-selecao-altura` no `#conc-detail-view`; se a altura de um deles mudar, ajustar a variável). A `.conc-subtabela--pendentes` desliga o `overflow:hidden` pelo mesmo motivo de `.conc-table-wrap`. A tabela de fornecedores, **ordenada por padrão pela situação, divergentes primeiro** (depois atenção, pendente, conciliada) (conta, fornecedor, saldo inicial, saldo residual, em aberto há mais de 3 meses, situação com ícone, e um **checkbox "Validado" no fim da linha**, pedido do usuário). O checkbox grava direto pelo mesmo `validar/` do botão "Marcar como validada" do detalhe, sem enviar `observacao` (o servidor mantém a já registrada), e o clique nele não expande a linha. **Quando a conta passa a validada** (por qualquer caminho, inclusive a validação automática), a linha expandida recolhe. Clicar numa linha expande: alertas, o **razão desde a primeira pendência** (todos os lançamentos, na ordem do arquivo e com a coluna de saldo do Questor, a partir da data do lançamento pendente mais antigo, incluindo os itens de pagamento parcial; os já vinculados aparecem apagados como "Vinculado #N" com o tipo, sem seleção, destacando o vínculo inteiro ao passar o mouse e com "Desfazer" na própria linha, para achar vínculo automático errado; um toggle **"Ocultar conciliados"** ao lado do título, **desmarcado por padrão** (pedido do usuário), esconde essas linhas vinculadas só por CSS, e a escolha fica em `estadoDe(conta.id).ocultarConciliados`, sobrevivendo ao re-render depois de vincular/desfazer), os **lançamentos não conciliados** (com checkbox para vínculo manual, valor em aberto e situação de cada um), lançamentos conciliados agrupados por vínculo (ocultos por padrão, com "Desfazer vínculo") e observação/validação da conta. diff --git a/portal_api/conciliacao_fornecedores/alertas.py b/portal_api/conciliacao_fornecedores/alertas.py index 72f7095..30cd24a 100644 --- a/portal_api/conciliacao_fornecedores/alertas.py +++ b/portal_api/conciliacao_fornecedores/alertas.py @@ -6,9 +6,11 @@ porque o contador pode desfazer ou criar vínculos a qualquer momento. título sem vínculo está em aberto pelo **valor cheio**, e um pagamento sem vínculo é "pagamento não vinculado", mesmo que a ferramenta desconfie de qual título ele paga. Essa desconfiança sai numa distribuição dos -pagamentos não vinculados sobre os títulos em aberto (primeiro o título do +pagamentos não vinculados sobre os títulos em aberto (antes de tudo, os +pares de mesmo valor, com o desempate do motor; depois o título do qual o pagamento parece ser parcela, título ≈ k × pagamento com k de 2 a -`MAX_PARCELAS`; senão o mais antigo com saldo), usada **só** para: +`MAX_PARCELAS`, primeiro a de arredondamento exato e depois a com pequena +diferença, ver `_parcela`; senão o mais antigo com saldo), usada **só** para: - escrever a sugestão de cada item (`SituacaoItem.sugestao`, texto à parte da `descricao`, que é só fato); @@ -28,9 +30,9 @@ from __future__ import annotations import unicodedata from dataclasses import dataclass, field from datetime import date -from decimal import Decimal +from decimal import ROUND_DOWN, Decimal -from .motor import TIPO_MANUAL, TIPO_PARCIAL, TIPO_TOLERANCIA +from .motor import TIPO_MANUAL, TIPO_PARCIAL, TIPO_TOLERANCIA, TOLERANCIA_PERCENTUAL_PADRAO, Tolerancia MESES_VENCIDO = 3 MAX_PARCELAS = 24 @@ -126,22 +128,45 @@ def _soma_meses(data: date, meses: int) -> date: return date(ano, mes, min(data.day, dias_no_mes[mes - 1])) -def _eh_parcela(valor_titulo: Decimal, valor_pagamento: Decimal) -> bool: +def _parcela(valor_titulo: Decimal, valor_pagamento: Decimal) -> tuple[int, Decimal, Decimal] | None: + """Se o pagamento parece parcela do título (título ≈ k × pagamento, k de + 2 a `MAX_PARCELAS`), devolve (k, parcela de referência, diferença do + pagamento para ela); senão None. `k` vai no texto da sugestão ("em 3x"). + + Diferença zero: bate com o arredondamento da divisão (até 1 centavo por + parcela no total). Diferença não zero: o pagamento fica a até + `TOLERANCIA_PERCENTUAL_PADRAO`% da parcela arredondada mais próxima + (possível juros/desconto), e a sugestão mostra quanto (pedido do usuário: + um pagamento de R$ 2.163,57 de um título de R$ 8.654,22 em 4 parcelas + não era reconhecido como parcela). Só o percentual, sem o piso fixo de + `TOLERANCIA_VALOR_PADRAO`, que em parcela pequena aceitaria quase tudo. + """ if valor_pagamento <= 0 or valor_pagamento >= valor_titulo: - return False + return None k = round(valor_titulo / valor_pagamento) if k < 2 or k > MAX_PARCELAS: - return False - # arredondamento de parcela: até 1 centavo por parcela - return abs(valor_titulo - valor_pagamento * k) <= Decimal("0.01") * k + return None + if abs(valor_titulo - valor_pagamento * k) <= Decimal("0.01") * k: + return k, valor_pagamento, Decimal("0") + parcela_baixa = (valor_titulo / k).quantize(Decimal("0.01"), rounding=ROUND_DOWN) + referencia = min((parcela_baixa, parcela_baixa + Decimal("0.01")), key=lambda p: abs(valor_pagamento - p)) + diferenca = valor_pagamento - referencia + if abs(diferenca) <= referencia * TOLERANCIA_PERCENTUAL_PADRAO / Decimal(100): + return k, referencia, diferenca + return None def _rotulo_titulo(credito: ItemAnalise) -> str: return f"{credito.data:%d/%m/%Y} ({_moeda(credito.valor)})" +def _natureza_diferenca(diferenca: Decimal) -> str: + """`diferenca` = título - pagamento (mesma convenção do vínculo).""" + return "a menos (possível desconto)" if diferenca > 0 else "a mais (possível juros/multa)" + + def _analisa_pendentes( - itens: list[ItemAnalise], data_base: date + itens: list[ItemAnalise], data_base: date, tolerancia: Tolerancia | None = None ) -> tuple[dict[int, SituacaoItem], Decimal, Decimal, Decimal, Decimal]: creditos = sorted((i for i in itens if i.natureza == "C"), key=lambda i: (i.data, i.id)) debitos = sorted((i for i in itens if i.natureza == "D"), key=lambda i: (i.data, i.id)) @@ -154,19 +179,102 @@ def _analisa_pendentes( total_nao_vinculado = Decimal("0") total_excedente = Decimal("0") + # 1º: pagamento de mesmo valor de um título, antes de tudo e antes de + # percorrer os pagamentos por data (senão um pagamento anterior poderia + # abater, pelo "mais antigo com saldo", o título do par exato de outro). + # Mesmo desempate do motor (`_pareia_exato`): data mais próxima, depois + # pagamento após o título antes de pagamento adiantado. Pedido do usuário: + # ao desfazer um vínculo automático exato, a sugestão apontava outro título. + candidatos_exatos = sorted( + ( + (abs((c.data - d.data).days), 1 if d.data < c.data else 0, c.data, c.id, d.id, c, d) + for c in creditos + for d in debitos + if c.valor == d.valor + ), + key=lambda t: t[:5], + ) + par_exato: dict[int, ItemAnalise] = {} # id do débito -> título + creditos_pareados: set[int] = set() + for *_, credito, debito in candidatos_exatos: + if debito.id in par_exato or credito.id in creditos_pareados: + continue + par_exato[debito.id] = credito + creditos_pareados.add(credito.id) + saldo_sugerido[credito.id] = Decimal("0") + pagos_sugeridos[credito.id].append(debito.id) + + # 2º: valor próximo, dentro da tolerância da conciliação (possível + # juros/desconto). Antes era vínculo automático do motor; o usuário pediu + # que vire só sugestão, apontando a possibilidade de juros/desconto. 1:1, + # menor diferença primeiro, depois data mais próxima. + par_proximo: dict[int, ItemAnalise] = {} # id do débito -> título + if tolerancia: + candidatos_proximos = sorted( + ( + (abs(c.valor - d.valor), abs((c.data - d.data).days), c.id, d.id, c, d) + for c in creditos + if c.id not in creditos_pareados + for d in debitos + if d.id not in par_exato + and abs(c.valor - d.valor) * 100 <= tolerancia.limite_centavos(int(c.valor * 100)) + ), + key=lambda t: t[:4], + ) + for *_, credito, debito in candidatos_proximos: + if debito.id in par_proximo or credito.id in creditos_pareados: + continue + par_proximo[debito.id] = credito + creditos_pareados.add(credito.id) + saldo_sugerido[credito.id] = Decimal("0") + pagos_sugeridos[credito.id].append(debito.id) + titulo_proximo = {credito.id: debito_id for debito_id, credito in par_proximo.items()} + por_id_debito = {d.id: d for d in debitos} + for debito in debitos: + if debito.id in par_proximo: + credito = par_proximo[debito.id] + diferenca = credito.valor - debito.valor + total_nao_vinculado += debito.valor + situacoes[debito.id] = SituacaoItem( + CATEGORIA_NAO_VINCULADO, + Decimal("0"), + "Pagamento não vinculado.", + f"Possível pagamento do título de {_rotulo_titulo(credito)}, com {_moeda(diferenca)} " + f"{_natureza_diferenca(diferenca)}. Vincule para confirmar.", + [credito.id], + ) + continue + if debito.id in par_exato: + credito = par_exato[debito.id] + total_nao_vinculado += debito.valor + situacoes[debito.id] = SituacaoItem( + CATEGORIA_NAO_VINCULADO, + Decimal("0"), + "Pagamento não vinculado.", + f"Mesmo valor do título de {_rotulo_titulo(credito)}. Vincule para confirmar.", + [credito.id], + ) + continue + restante = debito.valor usados: list[int] = [] - parcela_de = next( - (c for c in creditos if saldo_sugerido[c.id] >= debito.valor and _eh_parcela(c.valor, debito.valor)), - None, - ) + # Parcela exata (arredondamento) antes da com diferença, em qualquer título. + candidatas = [ + (c, parcela) + for c in creditos + if saldo_sugerido[c.id] >= debito.valor and (parcela := _parcela(c.valor, debito.valor)) + ] + escolhida = next((p for p in candidatas if p[1][2] == 0), candidatas[0] if candidatas else None) + parcela_de = escolhida[0] if escolhida else None ordem = ([parcela_de] if parcela_de else []) + [c for c in creditos if c is not parcela_de] + disponivel_usado = Decimal("0") # saldo dos títulos usados antes deste pagamento for credito in ordem: if restante <= 0: break if saldo_sugerido[credito.id] <= 0: continue + disponivel_usado += saldo_sugerido[credito.id] abatido = min(restante, saldo_sugerido[credito.id]) saldo_sugerido[credito.id] -= abatido restante -= abatido @@ -174,10 +282,36 @@ def _analisa_pendentes( pagos_sugeridos[credito.id].append(debito.id) titulos = [c for c in creditos if c.id in usados] - if parcela_de and usados == [parcela_de.id]: - sugestao = f"Possível parcela do título de {_rotulo_titulo(parcela_de)}. Vincule para confirmar." + if escolhida and usados == [parcela_de.id]: + qtd_parcelas, referencia, diferenca = escolhida[1] + # Quantidade de parcelas no texto (pedido do usuário). + if diferenca == 0: + sugestao = ( + f"Possível parcela do título de {_rotulo_titulo(parcela_de)}, em {qtd_parcelas}x de " + f"{_moeda(debito.valor)}. Vincule para confirmar." + ) + else: + sentido = "a mais" if diferenca > 0 else "a menos" + sugestao = ( + f"Possível parcela do título de {_rotulo_titulo(parcela_de)}, em {qtd_parcelas}x de " + f"{_moeda(referencia)}, com {_moeda(diferenca)} {sentido} (juros/desconto?). Vincule para confirmar." + ) elif titulos: - sugestao = f"Pode ser pagamento do(s) título(s) de {', '.join(_rotulo_titulo(c) for c in titulos)}." + # Sem par de valor nem parcela: só a ordem de antiguidade, sugestão + # fraca, e o texto deixa isso claro (pedido do usuário). + sugestao = ( + "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: " + f"{', '.join(_rotulo_titulo(c) for c in titulos)}." + ) + # Vários títulos: soma e o que sobra (pedido do usuário). A soma é + # do saldo ainda disponível de cada um, que pode ser menor que o + # valor do título se um pagamento anterior já o abateu em parte. + if len(titulos) > 1: + if restante > 0: + residual = f"o pagamento excede em {_moeda(restante)}" + else: + residual = f"saldo residual de {_moeda(disponivel_usado - debito.valor)} em aberto" + sugestao += f" Soma: {_moeda(disponivel_usado)}; {residual}." else: sugestao = "" @@ -202,6 +336,13 @@ def _analisa_pendentes( coberto = credito.valor - saldo_sugerido[credito.id] if not pagos: sugestao = "" + elif credito.id in titulo_proximo: + debito = por_id_debito[titulo_proximo[credito.id]] + diferenca = credito.valor - debito.valor + sugestao = ( + f"Possível pagamento em {debito.data:%d/%m/%Y} ({_moeda(debito.valor)}), com {_moeda(diferenca)} " + f"{_natureza_diferenca(diferenca)}. Vincule para confirmar." + ) elif saldo_sugerido[credito.id] <= 0: sugestao = f"Pode ter sido quitado por {len(pagos)} pagamento(s) não vinculado(s). Vincule para confirmar." else: @@ -226,9 +367,11 @@ def analisa_conta( saldo_final: Decimal, saldo_final_arquivo: Decimal, data_base: date, + tolerancia: Tolerancia | None = None, ) -> AnaliseConta: """`saldo_final` é o recalculado (saldo anterior + créditos - débitos); - `saldo_final_arquivo` o último impresso pelo Questor. Credor positivo.""" + `saldo_final_arquivo` o último impresso pelo Questor. Credor positivo. + `tolerancia` (a da conciliação) só alimenta a sugestão de valor próximo.""" pendentes = [i for i in itens if i.vinculo_id is None] # O saldo de cada pagamento parcial vinculado à mão continua em aberto: # entra como um título a mais (id negativo = -id do vínculo), com a data @@ -246,7 +389,7 @@ def analisa_conta( ) ) situacoes, total_aberto, total_vencido, total_nao_vinculado, total_excedente = _analisa_pendentes( - pendentes, data_base + pendentes, data_base, tolerancia ) alertas: list[AlertaConta] = [] diff --git a/portal_api/conciliacao_fornecedores/motor.py b/portal_api/conciliacao_fornecedores/motor.py index a846443..3df48bd 100644 --- a/portal_api/conciliacao_fornecedores/motor.py +++ b/portal_api/conciliacao_fornecedores/motor.py @@ -18,12 +18,23 @@ ordem: créditos, como NF + juros, somando um débito), limitada a `MAX_ITENS_COMBINACAO` itens numa janela de `JANELA_DIAS_COMBINACAO` dias, com teto de nós visitados por busca (subset-sum é exponencial). -4. **Compensação do segmento fechado**: o que sobrou num segmento que zerou +4. **Quitação dos títulos mais antigos**: para cada pagamento ainda + pendente, em ordem de data, se os títulos mais antigos em aberto (com + data até a do pagamento), somados em sequência, fecham **exatamente** o + pagamento, viram um vínculo. Não é um subconjunto qualquer (há uma única + sequência possível), então não tem o limite de itens nem de candidatos + da combinação. Pedido do usuário (2026-09-25): pagamentos de fretes + acumulados (BRASPRESS: R$ 234,40 = 4 títulos, R$ 616,82 = 7 títulos) que + a combinação não achava, por os títulos estarem fora dos 12 mais + próximos em data. +5. **Compensação do segmento fechado**: o que sobrou num segmento que zerou o saldo se compensa por definição; vira um único vínculo. -5. **Tolerância** (só no último segmento, o que ficou em aberto): 1:1 ou - combinação pequena com diferença até o limite configurado (valor fixo - **ou** percentual sobre o valor do título, o que for maior), sinalizada - como possível juros/desconto. +A diferença dentro da tolerância (possível juros/desconto) **não** vira +vínculo automático (pedido do usuário, 2026-09-25: antes era a etapa 5, 1:1 +ou 2 itens no último segmento): o par fica pendente e `alertas.py` o aponta +como sugestão, para o contador confirmar com um vínculo manual. +`TIPO_TOLERANCIA`/`_combina(..., tolerancia)` continuam existindo pelos +vínculos gravados antes disso. O que sobrar fica pendente, sem vínculo gravado. `alertas.py` distribui os débitos pendentes sobre os créditos pendentes (parcelas, pagamento parcial) @@ -55,6 +66,7 @@ TOLERANCIA_PERCENTUAL_PADRAO = Decimal("2.00") TIPO_EXATO = "exato" TIPO_COMBINACAO = "combinacao" +TIPO_SEQUENCIA = "sequencia" # quitação dos títulos mais antigos (etapa 4) TIPO_SALDO_ZERADO = "saldo_zerado" TIPO_TOLERANCIA = "tolerancia" TIPO_MANUAL = "manual" @@ -247,12 +259,35 @@ def _combina( return vinculos +def _quita_mais_antigos(pendentes: list[ItemConciliacao]) -> list[VinculoSugerido]: + """Etapa 4 (ver topo do módulo): cada pagamento, em ordem de data, contra + a sequência dos títulos mais antigos ainda em aberto, só se a soma fechar + exatamente (2 títulos ou mais; 1:1 já é da etapa de valor exato).""" + creditos = sorted((i for i in pendentes if i.natureza == "C"), key=lambda i: (i.data, i.indice)) + debitos = sorted((i for i in pendentes if i.natureza == "D"), key=lambda i: (i.data, i.indice)) + vinculos: list[VinculoSugerido] = [] + for debito in debitos: + soma = 0 + grupo: list[ItemConciliacao] = [] + for credito in creditos: + if credito.data > debito.data or soma >= debito.centavos: + break + soma += credito.centavos + grupo.append(credito) + if soma != debito.centavos or len(grupo) < 2: + continue + vinculos.append(_vinculo(TIPO_SEQUENCIA, [*grupo, debito])) + usados = {c.indice for c in grupo} + creditos = [c for c in creditos if c.indice not in usados] + return vinculos + + def _sem_vinculo(itens: list[ItemConciliacao], vinculos: list[VinculoSugerido]) -> list[ItemConciliacao]: usados = {indice for v in vinculos for indice in v.indices} return [i for i in itens if i.indice not in usados] -def concilia(itens: list[ItemConciliacao], tolerancia: Tolerancia) -> list[VinculoSugerido]: +def concilia(itens: list[ItemConciliacao]) -> list[VinculoSugerido]: """Roda as etapas descritas no topo do módulo para uma conta. `itens` precisa estar na ordem do arquivo (é ela que define o saldo corrente).""" vinculos: list[VinculoSugerido] = [] @@ -263,11 +298,9 @@ def concilia(itens: list[ItemConciliacao], tolerancia: Tolerancia) -> list[Vincu aberto = posicao == len(segmentos) - 1 locais = _pareia_exato(segmento) locais += _combina(_sem_vinculo(segmento, locais), TIPO_COMBINACAO, MAX_ITENS_COMBINACAO, None) + locais += _quita_mais_antigos(_sem_vinculo(segmento, locais)) restantes = _sem_vinculo(segmento, locais) - if not aberto: - if restantes: - locais.append(_vinculo(TIPO_SALDO_ZERADO, restantes)) - else: - locais += _combina(restantes, TIPO_TOLERANCIA, MAX_ITENS_TOLERANCIA, tolerancia) + if not aberto and restantes: + locais.append(_vinculo(TIPO_SALDO_ZERADO, restantes)) vinculos += locais return vinculos diff --git a/portal_api/migrations/0084_conciliacao_historico_vinculos.py b/portal_api/migrations/0084_conciliacao_historico_vinculos.py new file mode 100644 index 0000000..4705b1f --- /dev/null +++ b/portal_api/migrations/0084_conciliacao_historico_vinculos.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.7 on 2026-09-25 12:20 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('portal_api', '0083_conciliacao_vinculo_parcial'), + ] + + operations = [ + migrations.CreateModel( + name='ConciliacaoFornecedorVinculoHistorico', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('acao', models.CharField(choices=[('vinculou', 'Vinculou'), ('acrescentou', 'Acrescentou ao vínculo'), ('desfez', 'Desfez o vínculo')], max_length=20, verbose_name='Ação')), + ('vinculo_id_original', models.IntegerField(blank=True, null=True, verbose_name='Vínculo (id)')), + ('tipo_rotulo', models.CharField(blank=True, max_length=60, verbose_name='Tipo do vínculo')), + ('vinculo_automatico', models.BooleanField(default=False, verbose_name='Vínculo automático')), + ('lancamentos', models.JSONField(default=list, verbose_name='Lançamentos envolvidos')), + ('criado_em', models.DateTimeField(auto_now_add=True)), + ('conta', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='historico_vinculos', to='portal_api.conciliacaofornecedorconta')), + ('usuario', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Histórico de vínculo (Conciliação de fornecedores)', + 'verbose_name_plural': 'Histórico de vínculos (Conciliação de fornecedores)', + 'ordering': ['-criado_em', '-id'], + }, + ), + ] diff --git a/portal_api/migrations/0085_conciliacao_vinculo_sequencia.py b/portal_api/migrations/0085_conciliacao_vinculo_sequencia.py new file mode 100644 index 0000000..c069b7f --- /dev/null +++ b/portal_api/migrations/0085_conciliacao_vinculo_sequencia.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-09-25 14:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('portal_api', '0084_conciliacao_historico_vinculos'), + ] + + operations = [ + migrations.AlterField( + model_name='conciliacaofornecedorvinculo', + name='tipo', + field=models.CharField(choices=[('exato', 'Valor exato'), ('combinacao', 'Combinação de valores'), ('sequencia', 'Quitação dos títulos mais antigos'), ('saldo_zerado', 'Compensação até saldo zerado'), ('tolerancia', 'Com diferença (juros/desconto)'), ('manual', 'Manual'), ('parcial', 'Pagamento parcial')], max_length=20, verbose_name='Tipo'), + ), + ] diff --git a/portal_api/models.py b/portal_api/models.py index 96dfdee..9e7ac78 100644 --- a/portal_api/models.py +++ b/portal_api/models.py @@ -2769,6 +2769,7 @@ class ConciliacaoFornecedorVinculo(models.Model): TIPO_CHOICES = [ ("exato", "Valor exato"), ("combinacao", "Combinação de valores"), + ("sequencia", "Quitação dos títulos mais antigos"), ("saldo_zerado", "Compensação até saldo zerado"), ("tolerancia", "Com diferença (juros/desconto)"), ("manual", "Manual"), @@ -2822,3 +2823,39 @@ class ConciliacaoFornecedorLancamento(models.Model): def __str__(self) -> str: return f"{self.data:%d/%m/%Y} {self.natureza} {self.valor}" + + +class ConciliacaoFornecedorVinculoHistorico(models.Model): + """Registro de cada ação manual sobre vínculos de uma conta (vincular, + acrescentar lançamentos, desfazer — inclusive o desfazer de um vínculo + automático), para o contador saber quem vinculou/desvinculou o quê + (pedido do usuário). `lancamentos` é uma cópia dos lançamentos + envolvidos no momento da ação (seq., data, natureza, valor), porque o + vínculo desfeito é apagado e `vinculo_id` deixa de existir.""" + + ACAO_VINCULOU = "vinculou" + ACAO_ACRESCENTOU = "acrescentou" + ACAO_DESFEZ = "desfez" + ACAO_CHOICES = [ + (ACAO_VINCULOU, "Vinculou"), + (ACAO_ACRESCENTOU, "Acrescentou ao vínculo"), + (ACAO_DESFEZ, "Desfez o vínculo"), + ] + + conta = models.ForeignKey(ConciliacaoFornecedorConta, on_delete=models.CASCADE, related_name="historico_vinculos") + acao = models.CharField("Ação", max_length=20, choices=ACAO_CHOICES) + vinculo_id_original = models.IntegerField("Vínculo (id)", null=True, blank=True) + tipo_rotulo = models.CharField("Tipo do vínculo", max_length=60, blank=True) + vinculo_automatico = models.BooleanField("Vínculo automático", default=False) + # [{"id", "sequencia", "data", "natureza", "valor", "eh_saldo_anterior", "acrescentado"}] + lancamentos = models.JSONField("Lançamentos envolvidos", default=list) + usuario = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="+") + criado_em = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Histórico de vínculo (Conciliação de fornecedores)" + verbose_name_plural = "Histórico de vínculos (Conciliação de fornecedores)" + ordering = ["-criado_em", "-id"] + + def __str__(self) -> str: + return f"{self.get_acao_display()} #{self.vinculo_id_original} ({self.criado_em:%d/%m/%Y %H:%M})" diff --git a/portal_api/views.py b/portal_api/views.py index b531af3..7fdd221 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -65,6 +65,7 @@ from .models import ( ConciliacaoFornecedorConta, ConciliacaoFornecedorLancamento, ConciliacaoFornecedorVinculo, + ConciliacaoFornecedorVinculoHistorico, ContabilAchado, ContabilApuracao, ContabilApuracaoReprocessamento, @@ -5301,6 +5302,7 @@ def _conciliacao_analisa( saldo_final=conta.saldo_final, saldo_final_arquivo=conta.saldo_final_arquivo, data_base=conciliacao.data_base, + tolerancia=conciliacao_motor.Tolerancia(conciliacao.tolerancia_valor, conciliacao.tolerancia_percentual), ) @@ -5337,6 +5339,7 @@ def _conciliacao_conta_payload( "valor_em_aberto": str(item.valor_em_aberto), "descricao": item.descricao, "sugestao": item.sugestao, + "relacionados": item.relacionados, } def situacao(lanc: ConciliacaoFornecedorLancamento) -> dict[str, Any] | None: @@ -5348,6 +5351,9 @@ def _conciliacao_conta_payload( "valor_em_aberto": str(item.valor_em_aberto), "descricao": item.descricao, "sugestao": item.sugestao, + # ids do outro lado da sugestão (id negativo = saldo de um vínculo + # parcial, `-vinculo.id`), para a tela destacar as linhas no hover + "relacionados": item.relacionados, } return { @@ -5495,7 +5501,6 @@ def _conciliacao_resposta_conta(conta: ConciliacaoFornecedorConta, sincroniza_va def _conciliacao_grava_contas( conciliacao: ConciliacaoFornecedor, razao: conciliacao_parser.RazaoExtraido, - tolerancia: conciliacao_motor.Tolerancia, ) -> None: """Grava contas, vínculos sugeridos pelo motor e lançamentos. O saldo anterior diferente de zero vira um lançamento `eh_saldo_anterior`, @@ -5554,8 +5559,7 @@ def _conciliacao_grava_contas( [ conciliacao_motor.ItemConciliacao(indice=pos, data=linha.data, natureza=linha.natureza, valor=linha.valor) for pos, linha in enumerate(linhas) - ], - tolerancia, + ] ) vinculos = ConciliacaoFornecedorVinculo.objects.bulk_create( [ConciliacaoFornecedorVinculo(conta=conta, tipo=s.tipo, diferenca=s.diferenca) for s in sugeridos] @@ -5705,7 +5709,7 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet): ) conciliacao.arquivo.save(arquivo.name, ContentFile(conteudo), save=False) conciliacao.save() - _conciliacao_grava_contas(conciliacao, razao, tolerancia) + _conciliacao_grava_contas(conciliacao, razao) _conciliacao_valida_conciliadas(conciliacao) _conciliacao_atualiza_resumo(conciliacao) except Exception: @@ -5759,6 +5763,38 @@ def _conciliacao_tipo_vinculo_manual( return tipo, diferenca +def _conciliacao_registra_historico( + conta: ConciliacaoFornecedorConta, + acao: str, + vinculo: ConciliacaoFornecedorVinculo, + lancs: list[ConciliacaoFornecedorLancamento], + usuario: Usuario, + acrescentados: set[int] | None = None, +) -> None: + """Grava uma ação manual sobre vínculo no histórico da conta, com a cópia + dos lançamentos envolvidos (o vínculo desfeito some do banco).""" + ConciliacaoFornecedorVinculoHistorico.objects.create( + conta=conta, + acao=acao, + vinculo_id_original=vinculo.id, + tipo_rotulo=vinculo.get_tipo_display(), + vinculo_automatico=vinculo.tipo not in (conciliacao_motor.TIPO_MANUAL, conciliacao_motor.TIPO_PARCIAL), + lancamentos=[ + { + "id": lanc.id, + "sequencia": lanc.sequencia, + "data": lanc.data.isoformat(), + "natureza": lanc.natureza, + "valor": str(lanc.valor), + "eh_saldo_anterior": lanc.eh_saldo_anterior, + "acrescentado": bool(acrescentados and lanc.id in acrescentados), + } + for lanc in sorted(lancs, key=lambda l: (l.ordem, l.id)) + ], + usuario=usuario, + ) + + class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet): """Ajustes manuais numa conta: vincular lançamentos pendentes e marcar a conta como validada (com observação).""" @@ -5787,8 +5823,46 @@ class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet): conta=conta, tipo=tipo, diferenca=diferenca, criado_por=request.user ) ConciliacaoFornecedorLancamento.objects.filter(id__in=ids).update(vinculo=vinculo) + _conciliacao_registra_historico( + conta, ConciliacaoFornecedorVinculoHistorico.ACAO_VINCULOU, vinculo, lancs, request.user + ) return _conciliacao_resposta_conta(conta) + @action(detail=True, methods=["get"], url_path="historico-vinculos") + def historico_vinculos(self, request: Request, pk: str | None = None) -> Response: + """Histórico das ações manuais sobre vínculos da conta, mais recente + primeiro (botão "i" do razão).""" + conta = self.get_object() + registros = list(conta.historico_vinculos.select_related("usuario")) + # Histórico e contrapartida lidos na hora pelo id (o lançamento nunca + # muda depois da importação), para valer também nos registros que já + # existiam antes de a tela mostrá-los (pedido do usuário). + ids = {lanc["id"] for r in registros for lanc in r.lancamentos} + textos = { + lanc.id: (lanc.historico, _conciliacao_rotulo_contrapartida(lanc)) + for lanc in ConciliacaoFornecedorLancamento.objects.filter(conta=conta, id__in=ids) + } + + def com_texto(lanc: dict[str, Any]) -> dict[str, Any]: + historico, contrapartida = textos.get(lanc["id"], ("", "")) + return {**lanc, "historico": historico, "contrapartida": contrapartida} + + return Response( + [ + { + "id": r.id, + "acao": r.acao, + "acao_rotulo": r.get_acao_display(), + "tipo_rotulo": r.tipo_rotulo, + "vinculo_automatico": r.vinculo_automatico, + "lancamentos": [com_texto(lanc) for lanc in r.lancamentos], + "usuario_nome": r.usuario.nome if r.usuario else None, + "criado_em": r.criado_em.isoformat(), + } + for r in registros + ] + ) + @action(detail=True, methods=["post"]) def validar(self, request: Request, pk: str | None = None) -> Response: conta = self.get_object() @@ -5817,6 +5891,13 @@ class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet): vinculo = self.get_object() conta = vinculo.conta with transaction.atomic(): + _conciliacao_registra_historico( + conta, + ConciliacaoFornecedorVinculoHistorico.ACAO_DESFEZ, + vinculo, + list(vinculo.lancamentos.all()), + request.user, + ) vinculo.delete() return _conciliacao_resposta_conta(conta) @@ -5844,4 +5925,7 @@ class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet): vinculo.tipo, vinculo.diferenca = _conciliacao_tipo_vinculo_manual(conta.conciliacao, todos) vinculo.criado_por = request.user vinculo.save(update_fields=["tipo", "diferenca", "criado_por"]) + _conciliacao_registra_historico( + conta, ConciliacaoFornecedorVinculoHistorico.ACAO_ACRESCENTOU, vinculo, todos, request.user, ids + ) return _conciliacao_resposta_conta(conta) diff --git a/static/css/conciliacao-fornecedores.css b/static/css/conciliacao-fornecedores.css index 84468f1..d8efabc 100644 --- a/static/css/conciliacao-fornecedores.css +++ b/static/css/conciliacao-fornecedores.css @@ -662,16 +662,242 @@ button.conc-kpi.is-active { width: 28px; } -.conc-historico { - max-width: 380px; +/* Histórico e sugestão em uma linha só, cortados com reticências (texto + completo no `title`), para a altura da linha não variar com a largura da + tela (pedido do usuário). `max-width: 0` + largura em % faz a célula ceder + espaço em vez de alargar a tabela, igual à coluna de fornecedor. */ +.conc-subtabela td.conc-historico { + max-width: 0; + width: 30%; +} + +.conc-historico__texto, +.conc-historico__contrapartida { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .conc-historico__contrapartida { - display: block; font-size: 0.72rem; color: var(--text-muted); } +.conc-subtabela td.conc-situacao { + max-width: 0; + width: 34%; +} + +.conc-subtabela td.conc-situacao .conc-sugestao { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Conta saindo da lista por não caber mais no filtro (ex.: acabou de ser + validada). `removerConta()` tira a linha ao fim da animação. */ +.conc-table tbody tr.is-saindo { + animation: pidConcSaida 160ms ease-in forwards; + pointer-events: none; +} + +@keyframes pidConcSaida { + to { + opacity: 0; + } +} + +/* Botão "i" no canto da barra do razão: abre o histórico de vinculações. */ +.conc-info-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--border-subtle); + border-radius: 50%; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +.conc-info-btn:hover, +.conc-info-btn:focus-visible { + color: var(--accent); + border-color: rgba(var(--accent-rgb), 0.55); +} + +.conc-info-btn:active { + transform: scale(0.94); +} + +/* Versão menor, na linha vinculada do razão, ao lado do "Desfazer". */ +.conc-info-btn--linha { + width: 20px; + height: 20px; + margin-left: 2px; + vertical-align: middle; +} + +.conc-info-btn--linha svg { + width: 13px; + height: 13px; +} + +/* Modal do detalhe de um vínculo. */ +.conc-vinculo-detalhe__criterio { + margin: 0 0 var(--space-2); + font-size: 0.78rem; + color: var(--text-secondary); +} + +.conc-vinculo-detalhe__totais { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + margin-top: var(--space-2); + padding-top: var(--space-2); + border-top: 1px solid var(--border-subtle); + font-size: 0.8rem; + color: var(--text-secondary); +} + +.conc-vinculo-detalhe__totais strong { + color: var(--text-primary); +} + +/* Modal "Histórico de vinculações". */ +.conc-historico-vinc { + max-height: 60vh; + overflow-y: auto; +} + +.conc-historico-vinc__lista { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.conc-historico-vinc__item { + border: 1px solid var(--border-subtle); + border-left: 3px solid #2f9e6e; + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); +} + +.conc-historico-vinc__item--acrescentou { + border-left-color: var(--accent); +} + +.conc-historico-vinc__item--desfez { + border-left-color: var(--danger); +} + +.conc-historico-vinc__cabecalho { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--space-2); + font-size: 0.85rem; + margin-bottom: var(--space-1); +} + +.conc-historico-vinc__tipo { + font-size: 0.78rem; + color: var(--text-secondary); +} + +.conc-historico-vinc__quem { + margin-left: auto; + font-size: 0.75rem; + color: var(--text-muted); +} + +.conc-historico-vinc__lancs { + list-style: none; + margin: 0; + padding: 0; +} + +/* seq. | data | natureza | histórico (+ contrapartida) | valor | "acrescentado" */ +.conc-historico-vinc__lanc { + display: grid; + grid-template-columns: 90px 80px 64px minmax(0, 1fr) 100px auto; + align-items: center; + gap: var(--space-2); + padding: 3px 0; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.conc-historico-vinc__lanc + .conc-historico-vinc__lanc { + border-top: 1px solid var(--border-subtle); +} + +/* `min-width: 0` para as reticências de histórico/contrapartida valerem + dentro da coluna flexível do grid. */ +.conc-historico-vinc__texto { + min-width: 0; +} + +.conc-historico-vinc__texto .conc-historico__texto { + color: var(--text-primary); +} + +.conc-historico-vinc__lanc.is-acrescentado { + color: var(--text-primary); +} + +.conc-historico-vinc__seq { + font-weight: 600; + color: var(--text-primary); +} + +.conc-historico-vinc__novo { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + color: var(--accent); +} + +.conc-historico-vinc__vazio { + margin: var(--space-3) 0; + font-size: 0.85rem; + color: var(--text-muted); +} + +/* Texto completo do que foi cortado acima, no visual do Portal (mesmo de + `.dc-hover-tooltip__texto`). Criado por `pidConcMostraTooltip()`. */ +.conc-tooltip { + position: fixed; + /* acima de `.modal-overlay` (100) e do modal de confirmação (150): também + é usado no modal "Histórico de vinculações" */ + z-index: 200; + width: max-content; + max-width: 420px; + padding: var(--space-2) var(--space-3); + background: var(--bg-surface-raised); + color: var(--text-primary); + /* Borda na cor do tema + sombra média: com a borda neutra e a sombra + leve de `.dc-hover-tooltip__texto`, o balão se confundia com o fundo + escuro da tabela (pedido do usuário). */ + border: 1px solid rgba(var(--accent-rgb), 0.55); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); + font-size: 0.75rem; + font-style: normal; + line-height: 1.4; + white-space: normal; + pointer-events: none; + animation: pidFadeIn 120ms ease-out; +} + .conc-natureza { font-size: 0.7rem; font-weight: 700; @@ -1015,6 +1241,15 @@ button.conc-kpi.is-active { color: var(--text-primary); } +/* Hover na sugestão: a linha e as do outro lado da sugestão. Dourado (a + cor das situações "em aberto há mais de 3 meses"/"pagamento não + vinculado"): não o verde de `.is-grupo`, porque a sugestão ainda não é + vínculo, nem o roxo, que já é o de linha selecionada (`.is-selecionado`). */ +.pa-table .conc-subtabela--pendentes tbody tr.is-sugestao-grupo td { + background: rgba(var(--gold-rgb), 0.1); + color: var(--text-primary); +} + .conc-valor--saldo { color: var(--text-secondary); } diff --git a/static/js/acessos-gerais.js b/static/js/acessos-gerais.js index 40a7faa..f453ab6 100644 --- a/static/js/acessos-gerais.js +++ b/static/js/acessos-gerais.js @@ -122,7 +122,7 @@ document.addEventListener("DOMContentLoaded", async () => {
${pidConcEscape(criterio)}
+Carregando…
'; + try { + const registros = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/historico-vinculos/`); + corpo.innerHTML = registros.length + ? `Nenhuma vinculação ou desvinculação manual registrada nesta conta.
'; + } catch (erro) { + corpo.innerHTML = `${pidConcEscape(erro.message)}
`; + } +} + /** Saldo com o lado ("C" credor positivo, "D" devedor negativo), como o Questor imprime. */ function pidConcSaldo(valor) { const numero = Number(valor) || 0; @@ -79,7 +261,7 @@ function pidConcValorAbertoHtml(situacao) { function pidConcSituacaoHtml(situacao) { if (!situacao) return '