diff --git a/CLAUDE.md b/CLAUDE.md index 57e13ec..6b440a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ Pacotes Python puros (sem ORM), um por ferramenta — cada um com seu próprio ` | `custo_contratacao/` | "Simulação de Custo de Contratação" (Geradoc) — `tabelas.py` (faixas de INSS/IRRF), `calculo.py`, `pdf.py` via `reportlab`. | | `indicadores/` | "Indicador de Desempenho" (Geradoc) — `tipos.py`, `leiaute.py` (`openpyxl`), `pipeline.py`, `entregas.py`, `calculo.py`, `recibo.py` (PDF via `reportlab`), `departamentos.py`. | | `nao_conformidades/` | "Não Conformidades" (Relatórios > Qualidade) — leiautes dos exports do Sigsistem, `diff.py` (reabertura automática), `classificacao.py`, `pipeline.py`. | -| `conciliacao_fornecedores/` | "Conciliação de Fornecedores" (Utilitários) — `parser.py` (razão do Questor em XLSX/CSV), `motor.py` (vínculos automáticos débito × crédito), `atualizacao.py` (razão novo × anterior na atualização), `alertas.py` (situação/alertas recalculados a cada leitura), `exportacao.py` (XLSX), `relatorio.py` (relatório de análise para o contador: decisões tomadas e situações de risco). | +| `conciliacao_fornecedores/` | "Conciliação de Fornecedores" (Utilitários) — `parser.py` (razão do Questor em XLSX/CSV), `motor.py` (vínculos automáticos débito × crédito), `atualizacao.py` (razão novo × anterior na atualização), `alertas.py` (situação/alertas recalculados a cada leitura), `exportacao.py` (XLSX), `relatorio.py` (relatório de análise para o contador: decisões tomadas e situações de risco), `nomes.py` (semelhança entre nomes de fornecedor, para o risco de lançamento na conta de outro fornecedor). | | `dashboard_contabil/` | "Relatório Contábil" (Relatórios > Contabilidade) — `parser.py` (extração do PDF), `regras.py` (motor de auditoria), `formula.py` (avaliador de fórmula por `ast`), `indicadores.py`, `chaves.py` (chave natural de conta/linha, compartilhada por sincronização, observações e relatório), `exportacao.py` (XLSX), `resumo_pdf.py`. | ### API (sessão + CSRF, não token) diff --git a/portal_api/conciliacao_fornecedores/CHANGELOG.md b/portal_api/conciliacao_fornecedores/CHANGELOG.md index 849330c..d09b12f 100644 --- a/portal_api/conciliacao_fornecedores/CHANGELOG.md +++ b/portal_api/conciliacao_fornecedores/CHANGELOG.md @@ -307,3 +307,24 @@ Itens da revisão de interface de 30/09/2026 no relatório de análise para o co - A logo vem embutida como data URI (`logo_data_uri`, montado por `_logo_relatorio_data_uri()` em `views.py`, o mesmo do Relatório Contábil), com fallback para o `{% static %}`: o HTML salvo e aberto fora do Portal não perde mais a imagem. Mexe em `views.py`: exige reiniciar o `runserver`. - As datas de "validada em", "última ação" e "revisado em" passam de `d/m/y H:i` para `d/m/Y H:i`, como as demais datas do relatório. + +### Rodada 58 — Relatório: possível lançamento na conta de outro fornecedor (2026-10-01) + +Pedido do usuário, enquanto a revisão com IA não é feita: apontar no relatório o pagamento de um fornecedor que parece ser o título de outro de nome parecido, nos dois sentidos (exemplo dele: os fornecedores do Google). + +- **`nomes.py`** (novo, Python puro): `nomes_parecidos()` compara os nomes sem acento, pontuação, números (CPF/CNPJ), forma jurídica e palavras de ligação (`PALAVRAS_IGNORADAS`); letra solta é juntada à palavra seguinte (D'AGOSTINI = D AGOSTINI). Parecidos = mesmas palavras (CLEAR SALE S.A. em três contas, LWSA, TEX COURIER), ou os dois nomes de empresa (`eh_empresa()`, por `PALAVRAS_DE_EMPRESA`) começando pela mesma palavra de 4 letras ou mais fora de `PALAVRAS_GENERICAS` (GOOGLE BRASIL INTERNET × GOOGLE CLOUD BRASIL). Nome de pessoa física só conta igual (MARCIO FONTOURA LTDA × MARCIO LUIZ CORREIA DA LUZ não). Fica aqui para a revisão com IA reaproveitar. +- **`relatorio.py`**: risco médio `outro_fornecedor` ("Possível lançamento na conta de outro fornecedor"), por `_lancamentos_em_outro_fornecedor()`: pagamento sem vínculo, ou sobrando num vínculo manual/tolerância pago a maior que continua quitando sem ele (`_pago_a_maior_sem()`), contra título sem vínculo de **mesmo valor** na conta do outro fornecedor, até `JANELA_DIAS_OUTRO_FORNECEDOR` (60) dias antes ou depois; fica de fora o valor que tem par sem vínculo na própria conta e a conta transitória; cada lançamento em um par só, o de data mais próxima. Justificado pela observação de quem validou qualquer uma das duas contas. Nada gravado, nada vinculado. +- O caso do Google estava num vínculo manual: em 01/07/2026 os 4 pagamentos da GOOGLE BRASIL (R$ 2.645,84) vinculados ao título de R$ 2.563,29, pagos a maior em R$ 82,55; sem o R$ 81,80, que bate com o título de 02/07/2026 da GOOGLE CLOUD, sobram R$ 0,75. Só olhar os pagamentos sem vínculo não o achava. +- Resultado: empresa 2017, 1 ponto (o do Google); empresa 1751 (662 contas), nenhum. Na 1751 a regra de nomes também junta nomes sem relação que começam igual (ARTE, COSTA, IGUACU), mas o valor igual e a data filtram. + +Escolhas da implementação, não confirmadas: valor exato (sem tolerância), janela de 60 dias, severidade média, a regra de nomes. Mexe só em arquivos Python do pacote: exige reiniciar o `runserver`. + +### Rodada 59 — Arquivo do razão deixa de ser gravado (2026-10-01) + +Pedido do usuário: o arquivo anexado ficava em `media/conciliacao_fornecedores/` sem uso nenhum depois da leitura (a atualização do razão compara com os lançamentos do banco, e tela/relatório só mostram o nome), ocupando espaço no servidor. + +- `ConciliacaoFornecedor.arquivo` (`FileField`) removido, migração `0088_conciliacao_sem_arquivo`; fica só `nome_arquivo`. `validar_tamanho_arquivo_conciliacao()` continua em `models.py` porque a migração `0082` a referencia (o limite de 15MB segue validado no serializer). +- `create` e `atualizar` (views.py) leem o arquivo, gravam contas/lançamentos e descartam o conteúdo; saiu o tratamento de upload não transacional (apagar o arquivo em caso de erro, apagar o antigo depois da atualização). +- Antes, a pedido do usuário, as conciliações #22 (2017) e #23 (1751) foram excluídas para refazer os testes, e os arquivos de `media/conciliacao_fornecedores/` apagados; depois o usuário disse que não precisava excluir, e o único versionado no git foi restaurado (os demais têm cópia em `Projetos\Conciliação`). A conciliação #24 (2017), criada pelo usuário durante a mudança, ficou intacta (o arquivo dela também foi apagado, sem impacto). + +Mexe em models.py/views.py e migração: exige reiniciar o `runserver`. diff --git a/portal_api/conciliacao_fornecedores/CLAUDE.md b/portal_api/conciliacao_fornecedores/CLAUDE.md index 12d3a3b..6592184 100644 --- a/portal_api/conciliacao_fornecedores/CLAUDE.md +++ b/portal_api/conciliacao_fornecedores/CLAUDE.md @@ -74,7 +74,7 @@ Botão "Atualizar" na lista e "Atualizar razão" no detalhe: o contador anexa o - **Motor** roda de novo sobre a conta inteira com `concilia(itens, fixos)`: a segmentação usa o saldo corrente de todos os itens, mas só os livres entram em vínculo novo; `fixos` = já vinculados + os que o contador tirou de um vínculo automático (histórico `desfez` automático), para o motor não refazer o vínculo que ele desfez. Compensação de segmento fechado só se os livres somarem zero. - **Validação**: conta com lançamento novo ou removido e validada por uma pessoa volta a "a validar" (observação fica); a automática é reaplicada depois (decisão do usuário). **Revisão dos títulos antigos**: só é retirada se entrou título novo na lista de vencidos (comparando antes, na data-base anterior, e depois, na nova); decisão do usuário. - **Contas**: conta nova no arquivo é criada; conta que não veio no arquivo novo é **excluída** (substituição). Arquivo sem nenhuma conta em comum é recusado (provável empresa errada). -- **Cabeçalho**: período = o do arquivo novo (início do saldo anterior, fim = "Razão até"), data-base = dia da atualização, arquivo substituído (o antigo é apagado do disco depois do commit). Cada atualização gera um `ConciliacaoFornecedorAtualizacao` (quem, quando, arquivo, período novo e anterior, `resumo` com as contagens), exposto como `ultima_atualizacao` no payload e mostrado na lista, no detalhe e no relatório. +- **Cabeçalho**: período = o do arquivo novo (início do saldo anterior, fim = "Razão até"), data-base = dia da atualização, `nome_arquivo` trocado pelo do arquivo novo. Cada atualização gera um `ConciliacaoFornecedorAtualizacao` (quem, quando, nome do arquivo, período novo e anterior, `resumo` com as contagens), exposto como `ultima_atualizacao` no payload e mostrado na lista, no detalhe e no relatório. ## Relatório de análise (`relatorio.py`) @@ -82,13 +82,14 @@ Documento **para o contador**, não para o cliente (pedido do usuário: o escrit - **Parecer**: risco geral (alto/moderado/baixo) = maior severidade entre os itens de risco **sem justificativa**; síntese em frases; cards (saldo, em aberto, há mais de 3 meses com fornecedores revisados, pagamentos sem título, validação). - **1. Situações de risco**: alto = saldo recalculado ≠ arquivo, conta transitória com saldo, saldo devedor, pagamentos excedentes, conta **Divergente validada por uma pessoa sem observação**; médio = possível duplicidade (`JANELA_DIAS_DUPLICIDADE` = 7 dias entre pagamentos de mesmo valor na mesma conta, só grupo com algum pagamento sem vínculo), pagamentos ainda não vinculados, títulos há mais de 3 meses (com a tabela das faixas `FAIXAS_VENCIDOS_MESES` = 3 a 6, 6 a 12, mais de 12); baixo, informativo = juros (pago a maior) e descontos (pago a menor) dos vínculos `manual`/`tolerancia`. Mais a concentração do saldo em aberto (5 maiores, informativa). +- **Possível lançamento na conta de outro fornecedor** (médio, `outro_fornecedor`, rodada 58 de `CHANGELOG.md` nesta pasta; pedido do usuário enquanto a revisão com IA não existe): `_lancamentos_em_outro_fornecedor()` cruza as contas de nome parecido (`nomes.nomes_parecidos()`: mesmas palavras depois de tirar forma jurídica/ligação/números, ou dois nomes de empresa começando pela mesma palavra distintiva; pessoa física só com nome igual) e aponta o pagamento sem vínculo, ou sobrando num vínculo manual/tolerância pago a maior que continua quitando sem ele (`_pago_a_maior_sem()`), com título sem vínculo de **mesmo valor** no outro fornecedor, até `JANELA_DIAS_OUTRO_FORNECEDOR` (60) dias antes ou depois. Exclui valor com par sem vínculo na própria conta e a conta transitória; um par por lançamento, o de data mais próxima; justificado pela observação de qualquer uma das duas contas. Caso real: R$ 81,80 da GOOGLE CLOUD dentro de um vínculo manual da GOOGLE BRASIL. Valor exato, 60 dias e a regra de nomes são escolhas da implementação, não confirmadas; a regra de nomes junta também nomes sem relação que começam igual (ARTE, COSTA), filtrados na prática pelo valor e pela data. - **Justificativa**: item numa conta validada por uma pessoa com observação aparece como justificado, com a observação; nos títulos antigos, vale `vencidos_validados` (+ `vencidos_observacao`). A validação automática não justifica nada. Escolha da implementação, não confirmada. - **2. Decisões tomadas**: contas validadas por uma pessoa com situação ≠ conciliada (com a justificativa ou "Sem justificativa"), ajustes manuais por conta (contagem de vinculou/acrescentou/desfez automático/desfez manual, responsáveis, última ação), saldos de pagamento parcial, títulos antigos revisados. - **3. O que ainda falta decidir**: contas sem validação e fornecedores com títulos antigos sem revisão. **4. Anexo**: todas as contas. ## 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`, e a validação à parte dos títulos antigos `vencidos_validados`/`_por`/`_em`/`vencidos_observacao`) → `ConciliacaoFornecedorVinculo` (`tipo` exato/combinacao/saldo_zerado/tolerancia/manual/**parcial**, `diferenca` = créditos − débitos, `criado_por` só no manual) e `ConciliacaoFornecedorLancamento` (`vinculo` FK `SET_NULL`: excluir o vínculo devolve os lançamentos para pendente). `ConciliacaoFornecedorAtualizacao` (por conciliação, migração `0087`): registro de cada atualização do razão. `ConciliacaoFornecedorVinculoHistorico` (por conta): cada ação manual sobre vínculo (`acao` vinculou/acrescentou/desfez, este último inclusive para vínculo automático, mais `removido` = desfeito pela atualização do razão), com `usuario`, `criado_em`, `tipo_rotulo`/`vinculo_automatico` e **uma cópia dos lançamentos envolvidos** em `lancamentos` (JSON com seq., data, natureza, valor e `acrescentado`), porque o vínculo desfeito é apagado. Gravado por `_conciliacao_registra_historico()` dentro da mesma transação da ação. Vínculos criados pelo motor na importação **não** entram (não são ação de ninguém); ações anteriores à rodada 23 não têm registro. +`ConciliacaoFornecedor` (cabeçalho: empresa, data-base, período, `nome_arquivo`, tolerâncias, `resumo` JSON; **o arquivo do razão não é gravado**, rodada 59 de `CHANGELOG.md` nesta pasta: é lido na criação/atualização e nada mais o usa, então guardá-lo em `media/` só ocupava espaço; não reintroduzir um `FileField` sem um uso real para ele) → `ConciliacaoFornecedorConta` (saldos, `validada`/`validada_por`/`validada_em`, `observacao`, e a validação à parte dos títulos antigos `vencidos_validados`/`_por`/`_em`/`vencidos_observacao`) → `ConciliacaoFornecedorVinculo` (`tipo` exato/combinacao/saldo_zerado/tolerancia/manual/**parcial**, `diferenca` = créditos − débitos, `criado_por` só no manual) e `ConciliacaoFornecedorLancamento` (`vinculo` FK `SET_NULL`: excluir o vínculo devolve os lançamentos para pendente). `ConciliacaoFornecedorAtualizacao` (por conciliação, migração `0087`): registro de cada atualização do razão. `ConciliacaoFornecedorVinculoHistorico` (por conta): cada ação manual sobre vínculo (`acao` vinculou/acrescentou/desfez, este último inclusive para vínculo automático, mais `removido` = desfeito pela atualização do razão), 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 diff --git a/portal_api/conciliacao_fornecedores/nomes.py b/portal_api/conciliacao_fornecedores/nomes.py new file mode 100644 index 0000000..d6df5c0 --- /dev/null +++ b/portal_api/conciliacao_fornecedores/nomes.py @@ -0,0 +1,103 @@ +"""Semelhança entre nomes de fornecedor, para apontar lançamento que pode ter +caído na conta de outro fornecedor do mesmo grupo (ex.: GOOGLE BRASIL +INTERNET LTDA. × GOOGLE CLOUD BRASIL COMPUTACAO E SERVICOS DE DADOS) ou de +um fornecedor cadastrado em mais de uma conta (CLEAR SALE S.A. em três). + +Dois nomes são parecidos quando, depois de tirar acento, pontuação, números +(CPF/CNPJ no nome), forma jurídica e palavras de ligação, têm as mesmas +palavras, ou, sendo os dois de empresa (`eh_empresa()`), começam pela mesma +palavra distintiva (4 letras ou mais e fora de `PALAVRAS_GENERICAS`, que +começam nomes de empresas sem relação entre si: EXPRESSO PRINCESA × +EXPRESSO SAO MIGUEL). Um nome de cidade no início ainda passa (MEDIANEIRA +COMERCIO DE ALIMENTOS × MEDIANEIRA SHOW DE CHOCOLATES); o relatório só +aponta quando também há valor igual sem vínculo nos dois lados, o que deixa +esse caso raro. + +Python puro, sem ORM. +""" + +from __future__ import annotations + +import re +import unicodedata +from functools import lru_cache + +# Forma jurídica, ligação e termos de atividade que não identificam a empresa. +PALAVRAS_IGNORADAS = frozenset( + { + "LTDA", "LTD", "SA", "S", "A", "ME", "EPP", "EIRELI", "MEI", "CIA", "E", "DE", "DA", "DO", "DAS", "DOS", + "EM", "COM", "IND", "IMP", "EXP", "COMERCIO", "COMERCIAL", "INDUSTRIA", "IMPORTACAO", "EXPORTACAO", + "SERVICOS", "BRASIL", "BR", + } +) +# Primeira palavra comum a empresas sem relação entre si: não basta para +# considerar os nomes parecidos (só o nome inteiro igual). +PALAVRAS_GENERICAS = frozenset( + { + "AUTO", "POSTO", "CASA", "LOJA", "GRUPO", "CENTRO", "MERCADO", "SUPERMERCADO", "PANIFICADORA", "FARMACIA", + "EXPRESSO", "TRANSPORTES", "TRANSPORTADORA", "TRANSP", "DISTRIBUIDORA", "DISTRIBUICAO", "ELETRONICA", + "ELETRONICOS", "ELETRO", "ELETRICA", "MUSICAL", "MUSICAIS", "INSTRUMENTOS", "FERRAGENS", "FERRAMENTAS", + "MATERIAIS", "PRODUTOS", "SOLUCOES", "TECNOLOGIA", "INFORMATICA", "PAPELARIA", "LIVRARIA", "CLIENTES", + "FORNECEDORES", "DIVERSOS", "ASSOCIACAO", "COOPERATIVA", "INSTITUTO", "EMPRESA", "AGENCIA", "ALFA", + "GLOBAL", "NOVA", "NOVO", "SUPER", "MEGA", "MASTER", "PRIME", "TOP", + } +) +# Sem nenhuma destas palavras, o nome é tratado como de pessoa física: a +# primeira palavra seria só o prenome (MARCIO FONTOURA LTDA × MARCIO LUIZ +# CORREIA DA LUZ), então só o nome inteiro igual conta. +PALAVRAS_DE_EMPRESA = frozenset( + { + "LTDA", "LTD", "SA", "ME", "EPP", "EIRELI", "CIA", "COMERCIO", "COMERCIAL", "INDUSTRIA", "IND", "IMPORTACAO", + "EXPORTACAO", "SERVICOS", "INTERNET", "TECNOLOGIA", "TRANSPORTES", "DISTRIBUIDORA", + } +) +TAMANHO_MINIMO_PALAVRA = 4 + + +@lru_cache(maxsize=4096) +def _texto_normalizado(nome: str) -> tuple[str, ...]: + normalizado = unicodedata.normalize("NFKD", nome) + texto = "".join(c for c in normalizado if not unicodedata.combining(c)).upper() + # apóstrofo junta (D'AGOSTINI = DAGOSTINI); o resto da pontuação separa, + # e "S/A" ou "S.A." viram "SA" + texto = re.sub(r"['´`]", "", texto) + texto = re.sub(r"\bS\s*[/.]\s*A\b\.?", " SA ", texto) + return tuple(re.sub(r"[^A-Z0-9]+", " ", texto).split()) + + +def eh_empresa(nome: str) -> bool: + return any(p in PALAVRAS_DE_EMPRESA for p in _texto_normalizado(nome)) + + +def palavras(nome: str) -> tuple[str, ...]: + """Palavras que identificam o fornecedor, na ordem do nome.""" + resultado: list[str] = [] + letras = "" + for palavra in _texto_normalizado(nome): + if palavra.isdigit() or palavra in PALAVRAS_IGNORADAS: + continue + # letra solta vai para a palavra seguinte (D AGOSTINI = DAGOSTINI) + if len(palavra) == 1: + letras += palavra + continue + resultado.append(letras + palavra) + letras = "" + if letras: + resultado.append(letras) + return tuple(resultado) + + +def nomes_parecidos(nome_a: str, nome_b: str) -> bool: + palavras_a, palavras_b = palavras(nome_a), palavras(nome_b) + if not palavras_a or not palavras_b: + return False + if set(palavras_a) == set(palavras_b): + return True + primeira = palavras_a[0] + return ( + eh_empresa(nome_a) + and eh_empresa(nome_b) + and primeira == palavras_b[0] + and len(primeira) >= TAMANHO_MINIMO_PALAVRA + and primeira not in PALAVRAS_GENERICAS + ) diff --git a/portal_api/conciliacao_fornecedores/relatorio.py b/portal_api/conciliacao_fornecedores/relatorio.py index 2e21071..04645bf 100644 --- a/portal_api/conciliacao_fornecedores/relatorio.py +++ b/portal_api/conciliacao_fornecedores/relatorio.py @@ -24,12 +24,16 @@ from dataclasses import dataclass, field from datetime import date, datetime from decimal import Decimal -from . import alertas +from . import alertas, nomes from .motor import TIPO_MANUAL, TIPO_PARCIAL, TIPO_TOLERANCIA # Dois pagamentos de mesmo valor ao mesmo fornecedor com até 7 dias entre um # e outro são possível duplicidade (sugestão aceita pelo usuário). JANELA_DIAS_DUPLICIDADE = 7 +# Pagamento sem título numa conta × título de mesmo valor sem pagamento na +# conta de um fornecedor de nome parecido, com até 60 dias entre os dois +# (antes ou depois, como o par de mesmo valor do motor). +JANELA_DIAS_OUTRO_FORNECEDOR = 60 # Faixas dos títulos em aberto há mais de `alertas.MESES_VENCIDO` meses. FAIXAS_VENCIDOS_MESES = (6, 12) # Concentração: quantos fornecedores listar. @@ -341,6 +345,80 @@ def _duplicidades(conta: ContaRelatorio) -> list[list[LancamentoRelatorio]]: return [g for g in grupos if len(g) >= 2 and any(l.vinculo_id is None for l in g)] +@dataclass +class _ParOutroFornecedor: + conta_pagamento: ContaRelatorio + pagamento: LancamentoRelatorio + conta_titulo: ContaRelatorio + titulo: LancamentoRelatorio + + +def _pago_a_maior_sem(conta: ContaRelatorio, lanc: LancamentoRelatorio) -> VinculoRelatorio | None: + """Vínculo manual (ou por tolerância) pago a maior que continua quitando + os títulos sem este pagamento: o pagamento sobra nele e pode ser de outro + fornecedor (ex.: GOOGLE BRASIL, R$ 2.645,84 vinculados a um título de + R$ 2.563,29; sem o R$ 81,80 da GOOGLE CLOUD, a diferença cai a R$ 0,75).""" + vinculo = next((v for v in conta.vinculos if v.id == lanc.vinculo_id), None) + if vinculo and vinculo.tipo in (TIPO_MANUAL, TIPO_TOLERANCIA) and vinculo.diferenca < 0 and lanc.valor <= -vinculo.diferenca: + return vinculo + return None + + +def _livres(conta: ContaRelatorio) -> tuple[list[LancamentoRelatorio], list[LancamentoRelatorio]]: + """Pagamentos que podem ser de outro fornecedor (sem vínculo, ou sobrando + num vínculo pago a maior) e títulos sem vínculo, cujo valor não tem par + sem vínculo na própria conta: com um título de mesmo valor ali, o + pagamento quase certamente é dele, não de outro fornecedor.""" + pagamentos = [ + l + for l in conta.lancamentos + if l.natureza == "D" + and not l.eh_saldo_anterior + and l.valor > 0 + and (l.vinculo_id is None or _pago_a_maior_sem(conta, l)) + ] + titulos = [ + l for l in conta.lancamentos if l.natureza == "C" and not l.eh_saldo_anterior and l.vinculo_id is None and l.valor > 0 + ] + valores_pag = {l.valor for l in pagamentos if l.vinculo_id is None} + valores_tit = {l.valor for l in titulos} + return [l for l in pagamentos if l.valor not in valores_tit], [l for l in titulos if l.valor not in valores_pag] + + +def _lancamentos_em_outro_fornecedor(contas: list[ContaRelatorio]) -> list[_ParOutroFornecedor]: + """Pagamento sem título numa conta (ou sobrando num vínculo pago a maior, + `_pago_a_maior_sem()`) com título de mesmo valor sem pagamento na conta + de um fornecedor de nome parecido (`nomes.py`), nos + dois sentidos (ex.: R$ 81,80 pago em GOOGLE BRASIL INTERNET e o título de + R$ 81,80 em GOOGLE CLOUD BRASIL). Cada lançamento entra em um par só, o + de data mais próxima. Não grava nada: o contador decide se ajusta o + lançamento no Questor.""" + elegiveis = [c for c in contas if not alertas.eh_conta_transitoria(c.fornecedor)] + livres = {id(c): _livres(c) for c in elegiveis} + candidatos: list[tuple[int, date, int, int, _ParOutroFornecedor]] = [] + for i, conta_a in enumerate(elegiveis): + for conta_b in elegiveis[i + 1 :]: + if not nomes.nomes_parecidos(conta_a.fornecedor, conta_b.fornecedor): + continue + for conta_pag, conta_tit in ((conta_a, conta_b), (conta_b, conta_a)): + titulos = livres[id(conta_tit)][1] + for pag in livres[id(conta_pag)][0]: + for tit in titulos: + distancia = abs((pag.data - tit.data).days) + if tit.valor == pag.valor and distancia <= JANELA_DIAS_OUTRO_FORNECEDOR: + candidatos.append( + (distancia, pag.data, pag.id, tit.id, _ParOutroFornecedor(conta_pag, pag, conta_tit, tit)) + ) + usados: set[int] = set() + pares: list[_ParOutroFornecedor] = [] + for _, _, pag_id, tit_id, par in sorted(candidatos, key=lambda c: c[:4]): + if pag_id in usados or tit_id in usados: + continue + usados.update((pag_id, tit_id)) + pares.append(par) + return pares + + def _faixa(data: date, data_base: date) -> int: """Índice da faixa: 0 = 3 a 6 meses, 1 = 6 a 12, 2 = mais de 12.""" for indice, meses in enumerate(FAIXAS_VENCIDOS_MESES): @@ -396,6 +474,16 @@ def monta_relatorio(contas: list[ContaRelatorio], data_base: date, processado_po "com pelo menos um ainda sem título vinculado.", False, ), + ( + "outro_fornecedor", + alertas.SEVERIDADE_MEDIA, + "Possível lançamento na conta de outro fornecedor", + "Pagamento sem título (ou sobrando num vínculo pago a maior) numa conta e título de mesmo valor " + "sem pagamento na conta de um fornecedor " + f"de nome parecido, com até {JANELA_DIAS_OUTRO_FORNECEDOR} dias entre eles: o pagamento ou o título " + "pode ter sido lançado no fornecedor errado.", + False, + ), ( "pagamento_sem_titulo", alertas.SEVERIDADE_MEDIA, @@ -619,6 +707,33 @@ def monta_relatorio(contas: list[ContaRelatorio], data_base: date, processado_po ) ) + # entre contas: pagamento de um fornecedor × título de outro de nome parecido + for par in _lancamentos_em_outro_fornecedor(contas): + pag, tit, outra = par.pagamento, par.titulo, par.conta_titulo + vinculo = _pago_a_maior_sem(par.conta_pagamento, pag) if pag.vinculo_id else None + if vinculo: + situacao_pag = ( + f", no vínculo #{vinculo.id} pago a maior em {_moeda(-vinculo.diferenca)}" + f" (sem ele, a diferença fica em {_moeda(-vinculo.diferenca - pag.valor)})" + ) + else: + situacao_pag = " sem título nesta conta" + riscos["outro_fornecedor"].itens.append( + _item( + par.conta_pagamento, + pag.valor, + f"Pagamento de {_moeda(pag.valor)} em {_data(pag.data)}" + + (f" (seq. {pag.sequencia})" if pag.sequencia else "") + + situacao_pag + + f"; a conta {outra.conta_numero} ({outra.fornecedor}) tem título de mesmo valor" + + f" em {_data(tit.data)}" + + (f" (seq. {tit.sequencia})" if tit.sequencia else "") + + " sem pagamento.", + # justifica a observação de quem validou qualquer uma das duas contas + justificativa=_justificativa_conta(par.conta_pagamento) or _justificativa_conta(outra), + ) + ) + # concentração do saldo em aberto concentracao: list[ConcentracaoItem] = [] if total_aberto > 0: diff --git a/portal_api/dashboard_contabil/CHANGELOG.md b/portal_api/dashboard_contabil/CHANGELOG.md index 41167ea..4bab81f 100644 --- a/portal_api/dashboard_contabil/CHANGELOG.md +++ b/portal_api/dashboard_contabil/CHANGELOG.md @@ -536,3 +536,13 @@ Pedido do usuário: corrigir os problemas de interface apontados na revisão de **Não feito**: moeda negativa no relatório sai "R$ -1.234,50" e na tela "-R$ 1.234,50"; a diferença está no filtro `moeda` de `contabil_extras.py` (backend), fora do escopo desta rodada. Estado na URL, otimização de `dcDescendentes()` e as fontes do Google no relatório ficam para outra rodada. Só `.html`/`.css`/`.js` nesta parte: não exige reiniciar o `runserver` (as mudanças de backend do mesmo dia, sim). O JS passou no `node --check`, mas não foi executado num navegador neste ambiente: conferir na tela. + +### Rodada 156 — PDF anexado deixa de ser gravado (2026-10-01) + +Pedido do usuário, na mesma linha da Conciliação de Fornecedores (rodada 59 do `CHANGELOG.md` dela): o PDF de Balancete + DRE ficava em `media/contabil/apuracoes/` sem uso depois da extração (não era exposto em serializer nenhum, e `reprocessar()` sempre exige um upload novo), ocupando espaço no servidor. + +- `ContabilApuracao.arquivo` removido (migração `0089_contabil_indicador_sem_arquivo`, junto com o Indicador de Desempenho). `create()`/`reprocessar()` já liam o PDF da memória; saiu só a gravação e o tratamento de storage não transacional (apagar em caso de erro, apagar o antigo depois do reprocessamento, apagar na exclusão). +- O limite de 15MB passou para `ContabilApuracaoCreateSerializer` (`validar_tamanho_arquivo_contabil`): no model ele nunca rodava, porque `objects.create()` não chama `full_clean()`. +- Os PDFs já guardados ficam em `media/contabil/apuracoes/` (decisão do usuário: não excluir); sem a coluna, nenhum registro aponta mais para eles. Foram apagados por engano nesta rodada e restaurados do git só os versionados (1 de 12); os demais têm cópia em `Projetos\Balancetes`/Downloads. As 12 análises continuam abrindo normalmente. + +Mexe em models.py/views.py/serializers.py e migração: exige reiniciar o `runserver`. diff --git a/portal_api/dashboard_contabil/CLAUDE.md b/portal_api/dashboard_contabil/CLAUDE.md index 7df2780..ca04d12 100644 --- a/portal_api/dashboard_contabil/CLAUDE.md +++ b/portal_api/dashboard_contabil/CLAUDE.md @@ -151,7 +151,7 @@ Diferente de `IndicadorApuracaoViewSet`/`ImportacaoPlanoSaudeViewSet` (onde a ch 1. Lê o arquivo inteiro para memória (`arquivo.read()`) — nada em disco ainda. 2. `dashboard_contabil_pipeline.processa_apuracao(io.BytesIO(conteudo), _contabil_monta_historico)` — extrai cabeçalho/contas/DRE/Análise Vertical e, com a empresa/competência em mãos, chama `_contabil_monta_historico()` (função injetada, consulta o ORM) e roda as regras. `ContabilExtracaoInvalidaError` vira 400. 3. Confere se já existe apuração para essa empresa+competência (`.exists()`) → 400 com mensagem específica, **antes** de qualquer escrita (evita depender do `IntegrityError` cru, que devolveria 500). -4. Só então, dentro de `transaction.atomic()`, cria a `ContabilApuracao` (grava o arquivo via `ContentFile`) + `bulk_create` de contas / linhas DRE / linhas de Análise Vertical / achados. Um `except Exception` **fora** do `with` apaga o arquivo gravado se algo falhar no meio — upload de `FileField` não é transacional. +4. Só então, dentro de `transaction.atomic()`, cria a `ContabilApuracao` + `bulk_create` de contas / linhas DRE / linhas de Análise Vertical / achados. **O PDF não é gravado** (rodada 156 do CHANGELOG desta pasta, pedido do usuário: nada o lia depois da extração, só ocupava espaço em `media/`); o limite de 15MB fica no serializer (`validar_tamanho_arquivo_contabil`). `pipeline.processa_apuracao(origem, busca_historico)` recebe `busca_historico` como **função** (não uma lista pronta) exatamente por essa dependência: a chave de busca só existe depois da extração. @@ -161,7 +161,7 @@ Os achados de `variacao_atipica_dre` são ligados à linha certa por **`ordem`** `POST /api/contabil-apuracoes/{id}/reprocessar/` (multipart `arquivo`), bloqueado por `_contabil_garante_em_revisao()` — não existe reprocessar apuração concluída. Serve para corrigir uma análise feita com o PDF errado/incompleto **sem perder o trabalho já registrado**. O PDF novo precisa ser da **mesma** empresa+competência (senão 400 — trocar de empresa é uma análise nova). -Roda o mesmo `processa_apuracao()` de `create()`, troca o `arquivo` (apagando o antigo só **depois** do commit, pelo mesmo cuidado com storage não-transacional) e delega a resincronização para funções puras com **duas estratégias opostas**: +Roda o mesmo `processa_apuracao()` de `create()` (o PDF novo também não é gravado) e delega a resincronização para funções puras com **duas estratégias opostas**: - **`_contabil_sincroniza_contas()` / `_linhas_dre()` / `_linhas_analise_vertical()` — atualização no lugar (mesmo `id`), nunca delete+recria.** Casam cada linha extraída contra a existente por chave natural: `(codigo, descricao)` no Balancete, caminho na árvore + nível na DRE/Análise Vertical (ver `chaves.py` e "Observações" abaixo). Casada: atualiza os campos brutos no mesmo registro (`.save()`); se algum campo relevante mudou, força `validado=False` e `alterada_reprocessamento=True`, guardando o valor de antes em `valor_anterior_reprocessamento`/`valores_anterior_reprocessamento` (sempre lido **antes** de sobrescrever); senão preserva tudo, inclusive limpando esse campo. Sem match na extração nova: cria, com os defaults de sempre. Sobra no fim: `.delete()`. @@ -528,5 +528,5 @@ Cor por sinal só onde é seguro: `sign` (ROA/ROE/EBIT/EBITDA — positivo verde - **Duas cópias mantidas à mão**: os SVGs de ícone (Python + JS), a lógica de árvore/destaque (tela de revisão + relatório) e a montagem do caminho na chave de observação (`chaves.caminhos_linhas()` em Python, `dcAplicaChavesObs()` no JS, mais uma terceira cópia congelada dentro da migração `0078`, que por definição não pode importar código de aplicação). Mudança num lado exige o outro. - **Duas constantes conceituais duplicadas**: `PID_DC_NIVEL_ABERTO_PADRAO` (JS) e `_CONTABIL_NIVEL_ABERTO_PADRAO` (Python), hoje com papéis diferentes (sob demanda na revisão, padrão no relatório). - **`total_achados_pendentes` gera N+1** na listagem de apurações. -- **Testar exclusão nesta ViewSet destrói arquivo de verdade**: `perform_destroy()` chama `instance.arquivo.delete(save=False)`, e apagar arquivo do storage **não é revertido** por `transaction.set_rollback(True)` — o padrão de teste usado no resto desta documentação protege só o banco. Validar o caminho "204" contra uma apuração real já custou o PDF anexado dela (o registro voltou pelo rollback, o arquivo não). Nenhum dado analítico depende desse arquivo (`arquivo` não é exposto em serializer nenhum e `reprocessar()` sempre grava um upload novo), mas um teste futuro precisa de apuração descartável ou storage isolado. Ver `[[feedback_rollback_nao_desfaz_arquivo]]` na memória. +- **Exclusão não mexe mais em arquivo**: desde a rodada 156 do CHANGELOG desta pasta a apuração não tem `FileField`, então testar o `perform_destroy()` com `transaction.set_rollback(True)` é seguro aqui. Antes, validar o caminho "204" contra uma apuração real custou o PDF anexado dela (ver `[[feedback_rollback_nao_desfaz_arquivo]]` na memória, que continua valendo para as aplicações que ainda gravam arquivo). - **Mudança em `.py` exige reiniciar o `runserver`** para o usuário conseguir testar; mudança só em `.html`/`.css`/`.js` não exige. Isso já mascarou um diagnóstico ("não funciona" que não era bug de código). Ver `[[feedback_py_edit_precisa_restart_runserver]]` na memória. diff --git a/portal_api/indicadores/CHANGELOG.md b/portal_api/indicadores/CHANGELOG.md index ba12c44..b8acf92 100644 --- a/portal_api/indicadores/CHANGELOG.md +++ b/portal_api/indicadores/CHANGELOG.md @@ -145,3 +145,13 @@ Segunda etapa (01/10): - **Acabamento**: reticências "…", placeholders com exemplo ("Ex.: 08/2026…", "Ex.: 1.500,00…", nome do gerente), `inputmode="decimal"` nos campos numéricos, `name` nos campos, `color-scheme` também no "Vigente desde" (seguindo o tema claro), comentário e `cursor: pointer` errados removidos do campo de competência, popover do "Honorário Ajustado" centralizado por margem (a animação com `translateY` apagava o `translateX(-50%)` e o fazia pular meia largura), hover e `touch-action: manipulation` no cabeçalho do card, `overscroll-behavior: contain` no popup de Empresas sem Honorário e `tabular-nums` no total do card, nos percentuais e na tabela Empresas. Não feito (outra rodada, por decisão do pedido): estado da tela na URL e virtualização das listas. + +### Rodada 48 — Planilhas deixam de ser gravadas (2026-10-01) + +Pedido do usuário, na mesma linha da Conciliação de Fornecedores (rodada 59 do `CHANGELOG.md` dela): as planilhas de Serviços Tareffa e Honorários ficavam em `media/indicadores/` sem uso depois da criação (`gerar/` monta os recibos do que já está salvo), ocupando espaço no servidor. + +- `IndicadorApuracao.planilha_tareffa`/`planilha_honorarios` removidos (migração `0089_contabil_indicador_sem_arquivo`, junto com o Relatório Contábil). Como `leiaute.py` lê por caminho, `create()` grava as duas em arquivos temporários (`_salva_arquivo_temporario()`, com a extensão original, que o openpyxl exige) e os apaga num `finally`, com sucesso ou erro. Testado com as planilhas reais antes de apagá-las: 43 colaboradores lidos e temporários removidos. +- O limite de 15MB passou para `IndicadorApuracaoCreateSerializer` (`validar_tamanho_arquivo_indicador`): no model ele nunca rodava (`objects.create()` não chama `full_clean()`). +- As planilhas já guardadas ficam em `media/indicadores/` (decisão do usuário: não excluir); sem as colunas, nenhum registro aponta mais para elas. Foram apagadas por engano nesta rodada e restauradas do git (as 10 eram versionadas). As 2 apurações continuam abrindo normalmente. + +Mexe em models.py/views.py/serializers.py e migração: exige reiniciar o `runserver`. diff --git a/portal_api/indicadores/CLAUDE.md b/portal_api/indicadores/CLAUDE.md index 54f2ef6..8003ea0 100644 --- a/portal_api/indicadores/CLAUDE.md +++ b/portal_api/indicadores/CLAUDE.md @@ -22,7 +22,7 @@ Segunda ferramenta de Geradoc — substitui a apuração manual do indicador de - **"Corrigir Responsável"** (`#ind-corrigir-responsavel-btn`, popup próprio): busca uma empresa (por nome ou código, entre **todas** as empresas da apuração, não só as com problema de honorário — `empresasAgrupadasPorCodigo(() => true)`) e mostra, pra cada responsável dela (uma linha por `IndicadorApuracaoEmpresa`, ex.: "Valéria Bonete — Fiscal"), um `` exclui o colaborador atual das opções e nasce com um placeholder desabilitado ("Selecionar novo responsável…") pra nunca reatribuir sem escolha explícita. - **Checklist de revisão do RH** (`IndicadorApuracaoColaborador.validado`, migração `0031`): um checkbox no início de cada card (`.ind-colaborador-card__validado`, primeira coluna do grid do cabeçalho), sem relação com nenhum cálculo — só ajuda o RH a controlar quem já conferiu numa apuração com muitos colaboradores. Marcado, a borda do card inteiro fica verde (`.ind-colaborador-card.is-validado`, mesma largura de sempre, só muda a cor, pra não deslocar layout). `POST /api/indicadores-apuracoes-colaboradores/{id}/marcar-validado/` (`marcar_validado`, serializer `IndicadorApuracaoColaboradorValidadoSerializer` com `{validado}`) só grava o campo, sem chamar `recalcula_colaborador`. Diferente dos outros ajustes desta tela, o frontend **não** recarrega a apuração inteira depois de marcar/desmarcar: atualiza só o card clicado localmente. Os outros ajustes recarregam por `recarregarRevisao()`, que desde a revisão de interface de 30/09/2026 (rodada 47 do CHANGELOG) guarda os cards abertos, a rolagem e as respostas marcadas para o lote e os restaura depois do redesenho, então nenhum ajuste fecha mais o card em conferência; erro de rede reverte o checkbox e o estado em memória (mesmo padrão de outros toggles imediatos do app, ex. inativar usuário). O `