From ce611ad9cab07c59a74868bb566420847ee7bf3c Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 25 Sep 2026 15:45:38 -0300 Subject: [PATCH] =?UTF-8?q?Revis=C3=A3o=20do=20js.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + CLAUDE.md | 2 + plano.md | 55 ++++ portal_api/admin.py | 7 + .../conciliacao_fornecedores/CHANGELOG.md | 54 +++ portal_api/conciliacao_fornecedores/CLAUDE.md | 16 +- .../conciliacao_fornecedores/alertas.py | 181 ++++++++-- portal_api/conciliacao_fornecedores/motor.py | 55 +++- .../0084_conciliacao_historico_vinculos.py | 34 ++ .../0085_conciliacao_vinculo_sequencia.py | 18 + portal_api/models.py | 37 +++ portal_api/views.py | 92 +++++- static/css/conciliacao-fornecedores.css | 241 +++++++++++++- static/js/acessos-gerais.js | 6 +- static/js/api.js | 16 + static/js/calendar-individual.js | 4 +- static/js/conciliacao-fornecedores.js | 311 ++++++++++++++++-- static/js/dashboard-contabil.js | 21 +- static/js/dual-select.js | 4 +- static/js/favorites.js | 6 +- static/js/importacao-plano-saude.js | 53 ++- static/js/indicador-desempenho.js | 20 +- static/js/links-ferramentas.js | 6 +- static/js/nao-conformidades.js | 15 +- static/js/notifications.js | 9 +- static/js/profiles.js | 34 +- static/js/ramais-lookup.js | 8 +- static/js/ramais.js | 26 +- static/js/users-admin.js | 12 +- static/js/widgets.js | 14 +- 30 files changed, 1171 insertions(+), 190 deletions(-) create mode 100644 portal_api/migrations/0084_conciliacao_historico_vinculos.py create mode 100644 portal_api/migrations/0085_conciliacao_vinculo_sequencia.py 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 () => {
  • ${adminHtml}
  • `; @@ -156,7 +156,7 @@ document.addEventListener("DOMContentLoaded", async () => { return `
    -

    ${secao.nome}

    +

    ${pidEscapeHtml(secao.nome)}

    ${actionsHtml}
    @@ -200,7 +200,7 @@ document.addEventListener("DOMContentLoaded", async () => { (p) => ` ` ) .join(""); diff --git a/static/js/api.js b/static/js/api.js index 9851b8c..07c79f1 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -10,6 +10,22 @@ async function pidEnsureCsrfCookie() { await fetch(`${PID_API_BASE}/auth/csrf/`, { credentials: "include" }); } +/** + * Escape de texto para montar HTML por template string (`innerHTML`). Vale + * para conteúdo **e** para valor de atributo entre aspas duplas ou simples: + * trata `& < > " '`. Todo dado vindo do servidor, do usuário ou de arquivo + * anexado passa por aqui antes de entrar num `innerHTML` (senão um texto com + * HTML vira código executado no navegador de quem abre a tela: XSS). Nunca + * usar em HTML que já é intencional (texto rico sanitizado no servidor com + * nh3, como a Ajuda e as observações de Acessos Gerais). + */ +const PID_ESCAPE_HTML_MAPA = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; + +function pidEscapeHtml(texto) { + if (texto === null || texto === undefined) return ""; + return String(texto).replace(/[&<>"']/g, (ch) => PID_ESCAPE_HTML_MAPA[ch]); +} + function pidErrorMessageFrom(data) { if (!data) return "Erro ao processar a solicitação."; if (typeof data === "string") return data; diff --git a/static/js/calendar-individual.js b/static/js/calendar-individual.js index b541b52..af32901 100644 --- a/static/js/calendar-individual.js +++ b/static/js/calendar-individual.js @@ -115,7 +115,7 @@ document.addEventListener("DOMContentLoaded", async () => { function popularSelectCategorias(selecionarId) { modalCategoriaSelect.innerHTML = '' + - categorias.map((c) => ``).join(""); + categorias.map((c) => ``).join(""); if (selecionarId) modalCategoriaSelect.value = String(selecionarId); } @@ -136,7 +136,7 @@ document.addEventListener("DOMContentLoaded", async () => { const meusDepartamentos = me.departamentos || []; modalDepartmentSelect.innerHTML = meusDepartamentos - .map((d) => ``) + .map((d) => ``) .join(""); function updateDepartmentFieldVisibility() { diff --git a/static/js/conciliacao-fornecedores.js b/static/js/conciliacao-fornecedores.js index 2b85e21..9bbb68d 100644 --- a/static/js/conciliacao-fornecedores.js +++ b/static/js/conciliacao-fornecedores.js @@ -32,17 +32,199 @@ const PID_CONC_ROTULO_ALERTA = { const PID_CONC_SEVERIDADE = { alta: "Alta", media: "Média", baixa: "Baixa" }; +// Delegado ao `pidEscapeHtml` (api.js), que já escapa aspas: serve para +// conteúdo e para atributo. `pidConcEscapeAttr` fica como sinônimo. function pidConcEscape(texto) { - if (texto == null) return ""; - const div = document.createElement("div"); - div.textContent = String(texto); - return div.innerHTML; + return pidEscapeHtml(texto); } +function pidConcEscapeAttr(texto) { + return pidEscapeHtml(texto); +} + +/** Célula de histórico em uma linha só (reticências + texto completo no + * hover); a contrapartida segue na segunda linha, também cortada. */ +function pidConcHistoricoHtml(lanc) { + const texto = lanc.eh_saldo_anterior ? "Saldo anterior" : lanc.historico || ""; + const historico = lanc.eh_saldo_anterior ? "Saldo anterior" : pidConcEscape(texto); + const contrapartida = lanc.contrapartida + ? `${pidConcEscape(lanc.contrapartida)}` + : ""; + return `${historico}${contrapartida}`; +} + +// Tooltip do texto cortado com reticências (`[data-conc-tooltip]`), no +// visual do Portal em vez do `title` nativo (pedido do usuário; mesmo +// visual de `.dc-hover-tooltip__texto` do Relatório Contábil). Um único +// elemento `position: fixed` no body (as tabelas cortam `absolute`), +// delegado no document para valer nas linhas recriadas a cada re-render. +// Só aparece quando o texto está de fato cortado, salvo com +// `data-conc-tooltip-sempre` (rótulo de botão só com ícone). +let pidConcTooltipEl = null; + +function pidConcMostraTooltip(evento) { + const alvo = evento.target.closest && evento.target.closest("[data-conc-tooltip]"); + if (!alvo) return; + if (!("concTooltipSempre" in alvo.dataset) && alvo.scrollWidth <= alvo.clientWidth) return; + if (!pidConcTooltipEl) { + pidConcTooltipEl = document.createElement("div"); + pidConcTooltipEl.className = "conc-tooltip"; + pidConcTooltipEl.setAttribute("role", "tooltip"); + document.body.appendChild(pidConcTooltipEl); + } + pidConcTooltipEl.textContent = alvo.dataset.concTooltip; + pidConcTooltipEl.hidden = false; + + const margem = 8; + const rect = alvo.getBoundingClientRect(); + const largura = pidConcTooltipEl.offsetWidth; + const altura = pidConcTooltipEl.offsetHeight; + const esquerda = Math.max(margem, Math.min(rect.left, window.innerWidth - largura - margem)); + let topo = rect.top - altura - margem; + if (topo < margem) topo = rect.bottom + margem; + pidConcTooltipEl.style.left = `${esquerda}px`; + pidConcTooltipEl.style.top = `${topo}px`; +} + +function pidConcEscondeTooltip(evento) { + const alvo = evento.target.closest && evento.target.closest("[data-conc-tooltip]"); + if (!alvo || !pidConcTooltipEl || alvo.contains(evento.relatedTarget)) return; + pidConcTooltipEl.hidden = true; +} + +document.addEventListener("mouseover", pidConcMostraTooltip); +document.addEventListener("mouseout", pidConcEscondeTooltip); +window.addEventListener("scroll", () => { if (pidConcTooltipEl) pidConcTooltipEl.hidden = true; }, true); + function pidConcMoeda(valor) { return PID_CONC_MOEDA.format(Number(valor) || 0); } +const PID_CONC_ICONE_INFO = + ''; + +// ---- Histórico de vinculações da conta (botão "i" do razão) ---- +// Pedido do usuário: saber quem vinculou/desvinculou o quê e com qual seq. +// Modal criado sob demanda e injetado no body (padrão de ajuda-aplicacao.js). +let pidConcHistoricoModal = null; + +function pidConcHistoricoLancamentoHtml(lanc) { + const seq = lanc.eh_saldo_anterior ? "Saldo anterior" : `Seq. ${pidConcEscape(lanc.sequencia || "—")}`; + return ` +
  • + ${seq} + ${pidConcData(lanc.data)} + ${lanc.natureza === "C" ? "Crédito" : "Débito"} + + ${lanc.eh_saldo_anterior ? "Saldo anterior" : pidConcEscape(lanc.historico || "")} + ${lanc.contrapartida ? `${pidConcEscape(lanc.contrapartida)}` : ""} + + ${pidConcMoeda(lanc.valor)} + ${lanc.acrescentado ? 'acrescentado' : ""} +
  • `; +} + +function pidConcHistoricoRegistroHtml(registro) { + const origem = registro.vinculo_automatico ? "automático" : "manual"; + return ` +
  • +
    + ${pidConcEscape(registro.acao_rotulo)} + ${pidConcEscape(registro.tipo_rotulo)} · ${origem} + ${pidConcEscape(registro.usuario_nome || "—")} · ${pidConcDataHora(registro.criado_em)} +
    +
      ${registro.lancamentos.map(pidConcHistoricoLancamentoHtml).join("")}
    +
  • `; +} + +/** Abre o modal de informação (um só, reaproveitado pelo histórico de + * vinculações e pelo detalhe de um vínculo) e devolve o corpo a preencher. */ +function pidConcAbreModalInfo(titulo, subtitulo) { + if (!pidConcHistoricoModal) { + pidConcHistoricoModal = document.createElement("div"); + pidConcHistoricoModal.className = "modal-overlay"; + pidConcHistoricoModal.hidden = true; + pidConcHistoricoModal.innerHTML = ` + `; + document.body.appendChild(pidConcHistoricoModal); + const fechar = () => { pidConcHistoricoModal.hidden = true; }; + pidConcHistoricoModal.querySelector("[data-historico-fechar]").addEventListener("click", fechar); + pidConcHistoricoModal.addEventListener("click", (evento) => { + if (evento.target === pidConcHistoricoModal) fechar(); + }); + } + pidConcHistoricoModal.querySelector("[data-historico-titulo]").textContent = titulo; + pidConcHistoricoModal.querySelector("[data-historico-subtitulo]").textContent = subtitulo; + pidConcHistoricoModal.hidden = false; + return pidConcHistoricoModal.querySelector("[data-historico-corpo]"); +} + +// ---- Detalhe de um vínculo (botão "i" na linha vinculada) ---- +// Pedido do usuário: ver o que o vínculo automático considerou sem caçar as +// outras linhas no razão (podem estar longe). Tudo já vem no payload da conta. +const PID_CONC_CRITERIO_VINCULO = { + exato: "Um crédito e um débito de mesmo valor. Havendo mais de um candidato, fica o de data mais próxima (e, no empate, o pagamento feito depois do título).", + combinacao: "Vários lançamentos cuja soma fecha exatamente (até 6 itens, dentro de 120 dias, entre os de data mais próxima).", + sequencia: "O pagamento fecha exatamente com os títulos mais antigos que estavam em aberto, somados em sequência (com data até a do pagamento). Sem limite de itens, porque só existe uma sequência possível.", + saldo_zerado: "Trecho do razão em que o saldo da conta voltou a zero: o que sobrou nele se compensa por definição, então vira um único vínculo.", + tolerancia: "Débito e crédito (ou dois itens) com uma diferença pequena, dentro da tolerância da conciliação (valor fixo ou percentual do título, o maior). Possível juros/desconto. Critério usado só em importações até 25/09/2026: hoje esse caso fica pendente, como sugestão.", + manual: "Vinculado manualmente.", + parcial: "Pagamento parcial vinculado manualmente: a diferença continua em aberto.", +}; + +function pidConcAbreDetalheVinculo(conta, vinculoId, numero) { + const vinculo = conta.vinculos.find((v) => v.id === vinculoId); + if (!vinculo) return; + const lancs = conta.lancamentos.filter((l) => l.vinculo_id === vinculoId); + const creditos = lancs.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0); + const debitos = lancs.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0); + const diferenca = Math.round((creditos - debitos) * 100) / 100; + const automatico = vinculo.tipo !== "manual" && vinculo.tipo !== "parcial"; + let criterio = PID_CONC_CRITERIO_VINCULO[vinculo.tipo] || ""; + if (!automatico && vinculo.criado_por_nome) criterio = `${criterio} Por ${vinculo.criado_por_nome}.`; + const resultado = diferenca === 0 + ? "Fecha sem diferença." + : diferenca > 0 + ? `Créditos ${pidConcMoeda(diferenca)} maiores que os débitos${vinculo.tipo === "parcial" ? " (em aberto)" : ""}.` + : `Débitos ${pidConcMoeda(-diferenca)} maiores que os créditos.`; + + const corpo = pidConcAbreModalInfo(`Vínculo #${numero || "?"}`, `${conta.conta_numero} - ${conta.fornecedor}`); + corpo.innerHTML = ` +
    +
    + ${pidConcEscape(vinculo.tipo_rotulo)} + ${automatico ? "automático" : "manual"} +
    +

    ${pidConcEscape(criterio)}

    +
      ${lancs.map(pidConcHistoricoLancamentoHtml).join("")}
    +
    + Créditos ${pidConcMoeda(creditos)} + Débitos ${pidConcMoeda(debitos)} + ${resultado} +
    +
    `; +} + +async function pidConcAbreHistoricoVinculos(conta) { + const corpo = pidConcAbreModalInfo("Histórico de vinculações", `${conta.conta_numero} - ${conta.fornecedor}`); + corpo.innerHTML = '

    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 = ``; + } +} + /** 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 ''; const sugestao = situacao.sugestao - ? `Sugestão${pidConcEscape(situacao.sugestao)}` + ? `Sugestão${pidConcEscape(situacao.sugestao)}` : ""; return `${pidConcEscape(situacao.descricao)}${sugestao}`; } @@ -499,24 +681,28 @@ document.addEventListener("DOMContentLoaded", async () => { }); } - function contasFiltradas() { + /** A conta aparece com o filtro atual? Usado pela lista inteira e depois + * de cada ajuste numa conta (`aplicarRespostaConta`). */ + function contaPassaFiltro(c) { const termo = contasBusca.value.trim().toLowerCase(); - let lista = detalhe.contas.filter((c) => { - // Validadas ficam fora por padrão (pedido do usuário): a lista mostra - // o que ainda falta revisar. O chip "Validados" as mostra sempre. - if (c.validada && !mostrarValidadasInput.checked && filtroStatus !== "validada") return false; - if (filtroAlerta) { - const temAlerta = - filtroAlerta === "debito_excedente" ? Number(c.total_excedente) > 0 : c.alertas.some((a) => a.tipo === filtroAlerta); - if (!temAlerta) return false; - } else if (filtroStatus === "validada") { - // validação é uma marcação à parte da situação calculada - if (!c.validada) return false; - } else if (filtroStatus && c.status !== filtroStatus) { - return false; - } - return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo); - }); + // Validadas ficam fora por padrão (pedido do usuário): a lista mostra + // o que ainda falta revisar. O chip "Validados" as mostra sempre. + if (c.validada && !mostrarValidadasInput.checked && filtroStatus !== "validada") return false; + if (filtroAlerta) { + const temAlerta = + filtroAlerta === "debito_excedente" ? Number(c.total_excedente) > 0 : c.alertas.some((a) => a.tipo === filtroAlerta); + if (!temAlerta) return false; + } else if (filtroStatus === "validada") { + // validação é uma marcação à parte da situação calculada + if (!c.validada) return false; + } else if (filtroStatus && c.status !== filtroStatus) { + return false; + } + return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo); + } + + function contasFiltradas() { + let lista = detalhe.contas.filter(contaPassaFiltro); if (sortKey) { const extrator = sortValor[sortKey]; lista = lista.slice().sort((a, b) => pidConcCompara(extrator(a), extrator(b)) * sortDir); @@ -626,19 +812,40 @@ document.addEventListener("DOMContentLoaded", async () => { const estado = estadoDe(resposta.conta.id); estado.selecionados.clear(); renderCards(); - substituirConta(resposta.conta); + // Conta que deixou de caber no filtro (validada com "Mostrar validadas" + // desmarcado, desmarcada no chip "Validados", mudou de situação com um + // chip de situação ativo) sai da lista na hora. Antes ela só era + // redesenhada no lugar e saía no próximo render da lista inteira + // (pedido do usuário: "tem um atraso, às vezes preciso atualizar"). + if (contaPassaFiltro(resposta.conta)) substituirConta(resposta.conta); + else removerConta(resposta.conta.id); + } + + /** Tira as linhas da conta da lista com um fade curto. */ + function removerConta(contaId) { + const linhas = contasBody.querySelectorAll(`tr[data-conta-id="${contaId}"], tr[data-conta-detalhe-id="${contaId}"]`); + linhas.forEach((tr) => tr.classList.add("is-saindo")); + window.setTimeout(() => { + linhas.forEach((tr) => tr.remove()); + if (!contasBody.querySelector("tr[data-conta-id]")) { + contasVazia.hidden = false; + contasVazia.textContent = + !mostrarValidadasInput.checked && !filtroAlerta && !filtroStatus && !contasBusca.value.trim() + ? "Todos os fornecedores estão validados. Marque \"Mostrar validadas\" para vê-los." + : "Nenhum fornecedor para este filtro."; + } + }, 160); } function linhaLancamentoHtml(lanc, comCheckbox, selecionado) { const situacao = lanc.situacao; const valorAberto = pidConcValorAbertoHtml(situacao); - const historico = lanc.eh_saldo_anterior ? "Saldo anterior" : pidConcEscape(lanc.historico); return ` ${comCheckbox ? `` : ""} ${pidConcData(lanc.data)} ${pidConcEscape(lanc.sequencia || "—")} - ${historico}${lanc.contrapartida ? `${pidConcEscape(lanc.contrapartida)}` : ""} + ${pidConcHistoricoHtml(lanc)} ${lanc.natureza === "C" ? "Crédito" : "Débito"} ${pidConcMoeda(lanc.valor)} ${comCheckbox ? `${valorAberto}${pidConcSituacaoHtml(situacao)}` : ""} @@ -688,6 +895,7 @@ document.addEventListener("DOMContentLoaded", async () => {
    +
    `; if (razao.length) { @@ -704,12 +912,10 @@ document.addEventListener("DOMContentLoaded", async () => { painel.appendChild(secaoPend); function linhaRazaoHtml(lanc) { - const historico = lanc.eh_saldo_anterior ? "Saldo anterior" : pidConcEscape(lanc.historico); - const contrapartida = lanc.contrapartida ? `${pidConcEscape(lanc.contrapartida)}` : ""; const comum = ` ${pidConcData(lanc.data)} ${pidConcEscape(lanc.sequencia || "—")} - ${historico}${contrapartida} + ${pidConcHistoricoHtml(lanc)} ${lanc.natureza === "C" ? "Crédito" : "Débito"} ${pidConcMoeda(lanc.valor)} ${lanc.saldo_apos === null ? "—" : pidConcSaldo(lanc.saldo_apos)}`; @@ -735,6 +941,7 @@ document.addEventListener("DOMContentLoaded", async () => { Vinculado #${numeroVinculo.get(lanc.vinculo_id) || "?"} ${pidConcEscape(vinculo ? vinculo.tipo_rotulo : "")} · ${origem} + `; @@ -765,6 +972,43 @@ document.addEventListener("DOMContentLoaded", async () => { tr.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-grupo"))); tr.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-grupo"))); }); + // Sugestão: ao passar o mouse, destaca a própria linha e as do outro + // lado da sugestão (pedido do usuário, como o destaque do vínculo). Id + // negativo é o saldo de um vínculo parcial: destaca as linhas dele. + secaoPend.querySelectorAll("[data-conc-relacionados]").forEach((sugestaoEl) => { + const linha = sugestaoEl.closest("tr"); + const ids = sugestaoEl.dataset.concRelacionados.split(",").filter(Boolean).map(Number); + const grupo = () => [ + linha, + ...ids.flatMap((id) => + Array.from(secaoPend.querySelectorAll(id < 0 ? `tr[data-vinculo-id="${-id}"]` : `tr[data-lanc-id="${id}"]`)) + ), + ]; + sugestaoEl.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-sugestao-grupo"))); + sugestaoEl.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-sugestao-grupo"))); + // Duplo clique seleciona a linha e as relacionadas; se já estavam todas + // selecionadas, desmarca (pedido do usuário). Só as não vinculadas + // (com checkbox): o saldo de vínculo parcial fica só no destaque. + sugestaoEl.addEventListener("dblclick", (evento) => { + evento.stopPropagation(); + if (window.getSelection) window.getSelection().removeAllRanges(); + const checks = grupo() + .map((tr) => tr.querySelector("[data-lanc-check]")) + .filter(Boolean); + const marcar = !checks.every((check) => check.checked); + checks.forEach((check) => { + if (check.checked === marcar) return; + check.checked = marcar; + check.dispatchEvent(new Event("change")); + }); + }); + }); + + secaoPend.querySelector("[data-historico-vinculos]").addEventListener("click", (evento) => { + evento.stopPropagation(); + pidConcAbreHistoricoVinculos(conta); + }); + // "Ocultar conciliados": desmarcado por padrão (pedido do usuário); a // escolha fica no estado da conta e sobrevive ao re-render após um vínculo. const ocultarCheck = secaoPend.querySelector("[data-ocultar-conciliados]"); @@ -777,6 +1021,14 @@ document.addEventListener("DOMContentLoaded", async () => { }); } + secaoPend.querySelectorAll("[data-ver-vinculo]").forEach((btn) => { + btn.addEventListener("click", (evento) => { + evento.stopPropagation(); + const vinculoId = Number(btn.dataset.verVinculo); + pidConcAbreDetalheVinculo(conta, vinculoId, numeroVinculo.get(vinculoId)); + }); + }); + secaoPend.querySelectorAll("[data-desfazer-vinculo]").forEach((btn) => { btn.addEventListener("click", (evento) => { evento.stopPropagation(); @@ -832,6 +1084,9 @@ document.addEventListener("DOMContentLoaded", async () => { secaoPend.querySelectorAll(".conc-subtabela--pendentes tbody tr[data-lanc-id]:not([data-vinculo-id])").forEach((tr) => { tr.addEventListener("click", (evento) => { if (evento.target.closest("[data-lanc-check]")) return; + // A sugestão é reservada ao duplo clique (seleciona o grupo); o + // clique simples nela marcaria/desmarcaria a linha duas vezes antes. + if (evento.target.closest("[data-conc-relacionados]")) return; if (String(window.getSelection ? window.getSelection() : "").trim()) return; const check = tr.querySelector("[data-lanc-check]"); check.checked = !check.checked; diff --git a/static/js/dashboard-contabil.js b/static/js/dashboard-contabil.js index 1b308db..023430c 100644 --- a/static/js/dashboard-contabil.js +++ b/static/js/dashboard-contabil.js @@ -178,10 +178,11 @@ function pidDcFormatDataHora(iso) { }); } +// Delegado ao `pidEscapeHtml` (api.js), que também escapa aspas: esta função +// é usada dentro de atributos (`aria-label`, `value`), e a versão antiga por +// `textContent` deixava `"` passar (XSS por texto de observação). function pidDcEscapeHtml(texto) { - const div = document.createElement("div"); - div.textContent = texto == null ? "" : String(texto); - return div.innerHTML; + return pidEscapeHtml(texto); } // Barra de progresso de "Nova Análise"/"Reprocessar" — o processamento roda @@ -616,7 +617,7 @@ document.addEventListener("DOMContentLoaded", async () => { ${pidDcEscapeHtml(apuracao.codigo_empresa)} - ${pidDcEscapeHtml(apuracao.nome_empresa)} ${pidDcFontePdfAtipicaBadgeHtml(apuracao.fonte_pdf_atipica)} ${pidDcFormatCompetencia(apuracao.competencia)} ${apuracao.total_achados_pendentes} - ${PID_DC_STATUS_LABELS[apuracao.status] || apuracao.status} + ${pidDcEscapeHtml(PID_DC_STATUS_LABELS[apuracao.status] || apuracao.status)} ${pidDcEscapeHtml(apuracao.criado_por_nome || "-")} ${pidDcFormatData(apuracao.criado_em)} @@ -685,7 +686,7 @@ document.addEventListener("DOMContentLoaded", async () => { } function dcEscapeAttr(value) { - return (value === null || value === undefined ? "" : String(value)).replace(/"/g, """); + return pidEscapeHtml(value); } function dcCriarFiltroColuna(campo) { @@ -1310,8 +1311,8 @@ document.addEventListener("DOMContentLoaded", async () => { card.innerHTML = `
    - ${PID_DC_SEVERIDADE_LABELS[achado.severidade] || achado.severidade} - ${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status} + ${pidDcEscapeHtml(PID_DC_SEVERIDADE_LABELS[achado.severidade] || achado.severidade)} + ${pidDcEscapeHtml(PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status)} ${pidDcEscapeHtml(achado.titulo)}
    ${pidDcEscapeHtml(achado.mensagem)}
    @@ -2813,7 +2814,7 @@ document.addEventListener("DOMContentLoaded", async () => {
    - ${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status} + ${pidDcEscapeHtml(PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status)} ${pidDcEscapeHtml(achado.titulo)}
    ${pidDcEscapeHtml(achado.mensagem)}
    @@ -3251,7 +3252,7 @@ document.addEventListener("DOMContentLoaded", async () => { ` diff --git a/static/js/importacao-plano-saude.js b/static/js/importacao-plano-saude.js index ae82dc1..51cdafe 100644 --- a/static/js/importacao-plano-saude.js +++ b/static/js/importacao-plano-saude.js @@ -784,11 +784,11 @@ document.addEventListener("DOMContentLoaded", async () => { const statusTexto = imp.status === "concluida" ? "Concluída" : "Em revisão"; tr.innerHTML = ` - ${imp.codigo_empresa || "—"} - ${imp.nome_operadora} - ${tiposLabel(imp.tipos_lancamento)} + ${escapeHtml(imp.codigo_empresa || "—")} + ${escapeHtml(imp.nome_operadora)} + ${escapeHtml(tiposLabel(imp.tipos_lancamento))} ${statusTexto} - ${imp.criado_por_nome || "—"} + ${escapeHtml(imp.criado_por_nome || "—")} ${criadoEm}
    - +
    ` ) @@ -1829,27 +1829,24 @@ document.addEventListener("DOMContentLoaded", async () => { tabsEl.innerHTML = tabKeys .map((key) => { const label = key === "auditoria" ? "Auditoria" : key === "alteracoes" ? "Alterações" : PID_IPS_TIPO_LABELS[key] || key; - return ``; + return ``; }) .join(""); renderPanels(); } + // As duas delegam ao `pidEscapeHtml` (api.js), que trata `& < > " '`. O + // comentário antigo dizia que só o texto livre das regras de custeio + // precisava de escape completo e que o resto vinha de "fontes fixas", mas + // nome, CPF e detalhe da auditoria vêm do arquivo anexado da operadora: + // todo dado entra escapado (revisão de interface de 2026-09-25, XSS). function escapeAttr(value) { - return (value === null || value === undefined ? "" : String(value)).replace(/"/g, """); + return pidEscapeHtml(value); } - // Igual a escapeAttr, mas também escapa "<"/">"/"&" — usado pra inserir via - // innerHTML texto livre digitado pelo colaborador (nome/observações de uma - // regra de custeio salva), que ao contrário do resto dos campos desta tela - // (nomes de operadora/arquivo, vindos de fontes fixas) pode conter - // qualquer caractere. function escapeHtml(value) { - return (value === null || value === undefined ? "" : String(value)).replace( - /[&<>"]/g, - (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[ch] - ); + return pidEscapeHtml(value); } function resizeHandleHtml(campo, target) { @@ -2107,7 +2104,7 @@ document.addEventListener("DOMContentLoaded", async () => {
    ${concluida ? "" : `
    - @@ -2141,7 +2138,7 @@ document.addEventListener("DOMContentLoaded", async () => { let acaoHtml = `—`; if (item.resolvida) { acaoHtml = `Resolvido${ - item.linha_vinculada_nome ? ` — ${item.linha_vinculada_nome}` : "" + item.linha_vinculada_nome ? ` — ${escapeHtml(item.linha_vinculada_nome)}` : "" }`; } else if (importacaoAtual.status !== "concluida" && PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)) { acaoHtml = ``; @@ -2149,13 +2146,13 @@ document.addEventListener("DOMContentLoaded", async () => { return `
    ${acaoHtml}
    -
    ${item.nome || "—"}
    -
    ${item.cpf || "—"}
    +
    ${escapeHtml(item.nome || "—")}
    +
    ${escapeHtml(item.cpf || "—")}
    ${item.tipo === "T" ? "Titular" : "Dependente"}
    -
    ${PID_IPS_TIPO_LABELS[item.tipo_lancamento] || item.tipo_lancamento || "—"}
    +
    ${escapeHtml(PID_IPS_TIPO_LABELS[item.tipo_lancamento] || item.tipo_lancamento || "—")}
    ${Number(item.valor).toFixed(2).replace(".", ",")}
    -
    ${PID_IPS_MOTIVO_LABELS[item.motivo] || item.motivo}
    -
    ${item.detalhe || ""}
    +
    ${escapeHtml(PID_IPS_MOTIVO_LABELS[item.motivo] || item.motivo)}
    +
    ${escapeHtml(item.detalhe || "")}
    `; }) .join(""); @@ -2213,8 +2210,8 @@ document.addEventListener("DOMContentLoaded", async () => { ${acaoHtml} ${new Date(alt.criado_em).toLocaleString("pt-BR")} - ${PID_IPS_ALTERACAO_TIPO_LABELS[alt.tipo] || alt.tipo} - ${PID_IPS_TIPO_LABELS[alt.tipo_lancamento] || alt.tipo_lancamento || "—"} + ${escapeHtml(PID_IPS_ALTERACAO_TIPO_LABELS[alt.tipo] || alt.tipo)} + ${escapeHtml(PID_IPS_TIPO_LABELS[alt.tipo_lancamento] || alt.tipo_lancamento || "—")} ${alt.linha_nome ? escapeHtml(alt.linha_nome) : "—"} ${detalheHtml} ${alt.usuario_nome ? escapeHtml(alt.usuario_nome) : "—"} @@ -2247,7 +2244,7 @@ document.addEventListener("DOMContentLoaded", async () => { .map((key) => { const html = key === "auditoria" ? panelHtmlAuditoria() : key === "alteracoes" ? panelHtmlAlteracoes() : panelHtmlParaTipo(key); - return `
    ${html}
    `; + return `
    ${html}
    `; }) .join(""); wireColumnResize(panelsEl); @@ -2290,8 +2287,8 @@ document.addEventListener("DOMContentLoaded", async () => { `; }) diff --git a/static/js/indicador-desempenho.js b/static/js/indicador-desempenho.js index f49ce76..4ac3c63 100644 --- a/static/js/indicador-desempenho.js +++ b/static/js/indicador-desempenho.js @@ -82,16 +82,14 @@ function pidIndFormatData(iso) { return new Date(iso).toLocaleDateString("pt-BR"); } +// Delegado ao `pidEscapeHtml` (api.js), que também escapa aspas (a versão +// antiga por `textContent` não escapava, e a função é usada em atributos). function pidIndEscapeHtml(texto) { - const div = document.createElement("div"); - div.textContent = texto == null ? "" : String(texto); - return div.innerHTML; + return pidEscapeHtml(texto); } function pidIndEscapeAttr(texto) { - return String(texto == null ? "" : texto) - .replace(/&/g, "&") - .replace(/"/g, """); + return pidEscapeHtml(texto); } // "Cada gerente representa um grupo" (CLAUDE.md): agrupa os colaboradores da @@ -417,7 +415,7 @@ document.addEventListener("DOMContentLoaded", async () => { tr.innerHTML = ` ${pidIndFormatCompetencia(apuracao.competencia)} ${apuracao.total_colaboradores} - ${PID_IND_STATUS_LABELS[apuracao.status] || apuracao.status} + ${pidIndEscapeHtml(PID_IND_STATUS_LABELS[apuracao.status] || apuracao.status)} ${pidIndEscapeHtml(apuracao.criado_por_nome || "-")} ${pidIndFormatData(apuracao.criado_em)} @@ -1793,11 +1791,11 @@ document.addEventListener("DOMContentLoaded", async () => { ${pidIndEscapeHtml(criterio.nome)} ${pidIndEscapeHtml(criterio.departamento_nome)} - ${PID_IND_GRUPO_LABELS[criterio.grupo] || criterio.grupo} + ${pidIndEscapeHtml(PID_IND_GRUPO_LABELS[criterio.grupo] || criterio.grupo)} ${pidIndFormatPercentual(criterio.peso)} - ${PID_IND_PERIODO_LABELS[criterio.periodo] || criterio.periodo} + ${pidIndEscapeHtml(PID_IND_PERIODO_LABELS[criterio.periodo] || criterio.periodo)} ${criterio.papel_aplicavel ? PID_IND_TIPO_LABELS[criterio.papel_aplicavel] : "Qualquer"} - ${PID_IND_CALCULO_LABELS[criterio.calculo_automatico] || criterio.calculo_automatico} + ${pidIndEscapeHtml(PID_IND_CALCULO_LABELS[criterio.calculo_automatico] || criterio.calculo_automatico)} ${criterio.ativo ? "Ativo" : "Inativo"}
    - + ${iconHtml} - ${link.nome} + ${pidEscapeHtml(link.nome)} `; } diff --git a/static/js/nao-conformidades.js b/static/js/nao-conformidades.js index 065e2c2..a2d1eb8 100644 --- a/static/js/nao-conformidades.js +++ b/static/js/nao-conformidades.js @@ -21,11 +21,10 @@ function pidNcfFormatDateTime(iso) { return data.toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }); } +// Delegado ao `pidEscapeHtml` (api.js), que também escapa aspas (a versão +// antiga por `textContent` não escapava, e a função é usada em `value="..."`). function pidNcfEscapeHtml(texto) { - if (texto == null) return ""; - const div = document.createElement("div"); - div.textContent = String(texto); - return div.innerHTML; + return pidEscapeHtml(texto); } function pidNcfAtualizarContador(el, quantidade) { @@ -392,7 +391,7 @@ document.addEventListener("DOMContentLoaded", async () => { tr.appendChild(tdBtn); tr.insertAdjacentHTML( "beforeend", - `${oc.codigo} + `${pidNcfEscapeHtml(oc.codigo)} ${pidNcfFormatDate(oc.data_emissao)} ${pidNcfEscapeHtml(oc.area) || "—"} ${pidNcfEscapeHtml(oc.emissor_relato) || "—"} @@ -471,7 +470,7 @@ document.addEventListener("DOMContentLoaded", async () => { tr.appendChild(tdBtn); tr.insertAdjacentHTML( "beforeend", - `${oc.codigo} + `${pidNcfEscapeHtml(oc.codigo)} ${pidNcfFormatDate(oc.data_emissao)} ${pidNcfEscapeHtml(oc.area) || "—"} ${pidNcfEscapeHtml(oc.responsavel_analise) || "—"} @@ -525,7 +524,7 @@ document.addEventListener("DOMContentLoaded", async () => { const detalhe = await pidApiRequest(`/nao-conformidades/ocorrencias/${item.id}/`); const acoesHtml = (detalhe.acoes || []).length ? `

    Ações vinculadas

    ` : ""; extra.innerHTML = ` @@ -642,7 +641,7 @@ document.addEventListener("DOMContentLoaded", async () => { : ""; tr.insertAdjacentHTML( "beforeend", - `${acaoItem.ocorrencia_codigo}/${acaoItem.codigo} + `${pidNcfEscapeHtml(acaoItem.ocorrencia_codigo)}/${pidNcfEscapeHtml(acaoItem.codigo)} ${pidNcfEscapeHtml(acaoItem.ocorrencia_area) || "—"} ${pidNcfEscapeHtml(acaoItem.tipo_acao) || "—"} ${pidNcfEscapeHtml(acaoItem.ocorrencia_tipo_ocorrencia) || "—"} diff --git a/static/js/notifications.js b/static/js/notifications.js index bec4d5e..955190a 100644 --- a/static/js/notifications.js +++ b/static/js/notifications.js @@ -92,15 +92,18 @@ function pidSortEventsByDate(a, b) { return dateCompare !== 0 ? dateCompare : (a.horario || "").localeCompare(b.horario || ""); } +/** `date`/`title`/`breadcrumb` já saem daqui escapados (dado da API) — são + * interpolados direto no innerHTML de `render`/`renderHistory`, sem escapar de + * novo (o breadcrumb tem `>` fixo de propósito). */ function pidMapEventToNotification(ev) { return { id: `event-${ev.id}`, type: "event", - date: pidFormatNotifDate(ev.data), - title: ev.titulo.toUpperCase(), + date: pidEscapeHtml(pidFormatNotifDate(ev.data)), + title: pidEscapeHtml(ev.titulo.toUpperCase()), breadcrumb: ev.sou_dono ? "Menu > Calendário Individual" - : `Menu > Calendário Individual > Compartilhado por ${ev.dono_nome}`, + : `Menu > Calendário Individual > Compartilhado por ${pidEscapeHtml(ev.dono_nome)}`, }; } diff --git a/static/js/profiles.js b/static/js/profiles.js index af4ad69..b8ef47b 100644 --- a/static/js/profiles.js +++ b/static/js/profiles.js @@ -204,7 +204,7 @@ document.addEventListener("DOMContentLoaded", async () => { ${p.codigo} - ${p.nome} + ${pidEscapeHtml(p.nome)} ${p.gerenciaPermissoes ? 'Acesso total' : ""} @@ -229,9 +229,9 @@ document.addEventListener("DOMContentLoaded", async () => { moduleListEl.innerHTML = PID_MODULES.map((m, idx) => { const enabled = draftProfile.permissions[m.key].enabled; return ` - `; }).join(""); @@ -241,8 +241,8 @@ document.addEventListener("DOMContentLoaded", async () => { const match = !term || tool.label.toLowerCase().includes(term); return ` `; } @@ -259,12 +259,12 @@ document.addEventListener("DOMContentLoaded", async () => { return `
    -
    @@ -295,7 +295,7 @@ document.addEventListener("DOMContentLoaded", async () => { }
    ${ @@ -339,8 +339,8 @@ document.addEventListener("DOMContentLoaded", async () => { (u) => ` - ${u.nome} - ${u.email || "—"} + ${pidEscapeHtml(u.nome)} + ${pidEscapeHtml(u.email || "—")} ` ) .join("") @@ -410,9 +410,9 @@ document.addEventListener("DOMContentLoaded", async () => { ? items .map( (a) => ` - ` ) .join("") @@ -428,7 +428,7 @@ document.addEventListener("DOMContentLoaded", async () => { appManageThead.innerHTML = ` Perfil - ${tools.map((tool) => `${tool.label.split(" (")[0]}`).join("")} + ${tools.map((tool) => `${pidEscapeHtml(tool.label.split(" (")[0])}`).join("")} `; const rows = profiles @@ -442,11 +442,11 @@ document.addEventListener("DOMContentLoaded", async () => { const perm = p.permissions[entry.moduleKey]; return ` - ${p.nome} ${p.ativo ? "" : 'Inativo'} + ${pidEscapeHtml(p.nome)} ${p.ativo ? "" : 'Inativo'} ${tools .map( (tool) => ` - ` ) diff --git a/static/js/ramais-lookup.js b/static/js/ramais-lookup.js index 18c4fcc..e3bbfcc 100644 --- a/static/js/ramais-lookup.js +++ b/static/js/ramais-lookup.js @@ -36,7 +36,7 @@ document.addEventListener("DOMContentLoaded", async () => { const botoes = [``].concat( departamentos.map( (d) => - `` + `` ) ); deptoFiltrosEl.innerHTML = botoes.join(""); @@ -115,9 +115,9 @@ document.addEventListener("DOMContentLoaded", async () => { .map( (r) => ` - ${r.nome_exibicao} - ${r.departamento_exibicao || "—"} - ${r.numero_exibicao || "—"} + ${pidEscapeHtml(r.nome_exibicao)} + ${pidEscapeHtml(r.departamento_exibicao || "—")} + ${pidEscapeHtml(r.numero_exibicao || "—")} ` ) .join("") diff --git a/static/js/ramais.js b/static/js/ramais.js index e99eeca..76ef623 100644 --- a/static/js/ramais.js +++ b/static/js/ramais.js @@ -232,7 +232,7 @@ document.addEventListener("DOMContentLoaded", async () => { let editingId = null; function usuarioOptionsHtml() { - return usuariosDisponiveis.map((u) => ``).join(""); + return usuariosDisponiveis.map((u) => ``).join(""); } function rowHtml(ramal) { @@ -241,11 +241,11 @@ document.addEventListener("DOMContentLoaded", async () => { const rowClass = aniversario ? "ram-row--aniversario" : ausente ? "ram-row--ausente" : ""; const nomeHtml = aniversario - ? `${ramal.nome_exibicao} 🎉 Feliz Aniversário` - : ramal.nome_exibicao; + ? `${pidEscapeHtml(ramal.nome_exibicao)} 🎉 Feliz Aniversário` + : pidEscapeHtml(ramal.nome_exibicao); const numeroHtml = ramal.numero_exibicao - ? ramal.numero_exibicao + ? pidEscapeHtml(ramal.numero_exibicao) : `Pendente`; const ausenteBadgeHtml = ausente @@ -270,7 +270,7 @@ document.addEventListener("DOMContentLoaded", async () => { return ` ${nomeHtml} - ${ramal.departamento_exibicao || "—"} + ${pidEscapeHtml(ramal.departamento_exibicao || "—")} ${ramalHtml}
    ${editBtnHtml}${deleteBtnHtml}
    `; @@ -317,10 +317,10 @@ document.addEventListener("DOMContentLoaded", async () => { return ` - ${tel.nome} - ${tel.ramal || "—"} - ${tel.telefone || "—"} - ${tel.observacoes || "—"} + ${pidEscapeHtml(tel.nome)} + ${pidEscapeHtml(tel.ramal || "—")} + ${pidEscapeHtml(tel.telefone || "—")} + ${pidEscapeHtml(tel.observacoes || "—")}
    ${editBtnHtml}${deleteBtnHtml}
    `; } @@ -379,9 +379,9 @@ document.addEventListener("DOMContentLoaded", async () => { return ` - ${fun.comando} - ${fun.funcao} - ${fun.resumo || "—"} + ${pidEscapeHtml(fun.comando)} + ${pidEscapeHtml(fun.funcao)} + ${pidEscapeHtml(fun.resumo || "—")}
    ${editBtnHtml}${deleteBtnHtml}
    `; } @@ -480,7 +480,7 @@ document.addEventListener("DOMContentLoaded", async () => { } function fillAusenciaViewFields(ausencia, ramal) { - ausViewUsuario.innerHTML = ``; + ausViewUsuario.innerHTML = ``; ausViewDataInicio.value = ausencia.data_inicio || ""; ausViewHoraInicio.value = ausencia.hora_inicio ? ausencia.hora_inicio.slice(0, 5) : ""; ausViewDataFim.value = ausencia.data_fim || ""; diff --git a/static/js/users-admin.js b/static/js/users-admin.js index 616d8c7..0ed80d5 100644 --- a/static/js/users-admin.js +++ b/static/js/users-admin.js @@ -120,7 +120,7 @@ document.addEventListener("DOMContentLoaded", async () => { // Célula vazia fica destacada (itálico, cor apagada) — ordenar a coluna // agrupa quem ainda não tem aquele código/ramal cadastrado. function celulaCodigo(valor) { - return valor ? valor : '—'; + return valor ? pidEscapeHtml(valor) : '—'; } function renderList() { @@ -143,14 +143,14 @@ document.addEventListener("DOMContentLoaded", async () => { .map( (u) => ` - ${u.username} - ${u.nome} + ${pidEscapeHtml(u.username)} + ${pidEscapeHtml(u.nome)} ${celulaCodigo(u.codigo_folha)} ${celulaCodigo(u.codigo_questor)} ${celulaCodigo(u.codigo_tareffa)} ${celulaCodigo(u.codigo_contabit)} ${celulaCodigo(u.ramal)} - ${profileNomes(u.perfis)} + ${pidEscapeHtml(profileNomes(u.perfis))} ${u.is_active ? "Ativo" : "Inativo"}
    @@ -180,7 +180,7 @@ document.addEventListener("DOMContentLoaded", async () => { (p) => ` ` ) .join(""); @@ -211,7 +211,7 @@ document.addEventListener("DOMContentLoaded", async () => {