Revisão das regras de armazenamento dos arquivos.
This commit is contained in:
parent
30cb81ccc8
commit
236b46c65d
@ -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)
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
103
portal_api/conciliacao_fornecedores/nomes.py
Normal file
103
portal_api/conciliacao_fornecedores/nomes.py
Normal file
@ -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
|
||||
)
|
||||
@ -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:
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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 `<select>` com os demais colaboradores da apuração pra reatribuir aquele papel. Reatribuir chama `POST /api/indicadores-apuracoes-empresas/{id}/trocar-responsavel/` (`IndicadorApuracaoEmpresaViewSet.trocar_responsavel`, serializer `IndicadorApuracaoEmpresaTrocarResponsavelSerializer` com `{colaborador_id}`), que só troca a FK `colaborador` da linha (`codigo_empresa`/`tipo`/honorário continuam os mesmos) e recalcula **os dois** colaboradores envolvidos (o que perdeu a empresa e o que ganhou) — validado no backend contra: colaborador de outra apuração, colaborador igual ao atual, e colaborador que já é responsável por essa mesma empresa/tipo (evitaria duas linhas duplicadas pra ele). O `<select>` 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 `<label>` inteiro (não só o `<input>`) precisa ficar de fora do gate de clique que expande/recolhe o card no cabeçalho, senão um clique na área do label (fora do glifo do checkbox) expande/recolhe o card ao mesmo tempo que marca/desmarca o validado — resultado de como labels HTML disparam dois eventos de clique encadeados.
|
||||
- **Forçar SIM num critério automático não vira 100% na média** — a média ponderada usa o percentual real medido (`_fracao_atingida` em `calculo.py`), mesmo que o RH marque SIM por cima; só critério manual (sem `percentual_calculado`) é binário SIM=100%/resto=0%. Pra dar crédito cheio apesar do percentual medido baixo, o RH ajusta o percentual agregado direto (nível 2 acima), não o critério.
|
||||
- **`create()` é atômico**: `IndicadorApuracaoViewSet.create()` roda o pipeline inteiro (parse das 2 planilhas + persistência de colaboradores/empresas/respostas) dentro de `transaction.atomic()` — uma falha no meio (planilha fora do leiaute, overflow decimal) desfaz tudo no banco e apaga os 2 arquivos recém-gravados em `MEDIA_ROOT` (upload não é transacional), devolvendo 400 genérico.
|
||||
- **`create()` é atômico**: `IndicadorApuracaoViewSet.create()` roda o pipeline inteiro (parse das 2 planilhas + persistência de colaboradores/empresas/respostas) dentro de `transaction.atomic()` — uma falha no meio (planilha fora do leiaute, overflow decimal) desfaz tudo no banco, devolvendo 400 genérico. **As 2 planilhas não são gravadas** (rodada 48 do CHANGELOG desta pasta, pedido do usuário: nada as lia depois da criação): como `leiaute.py` pede caminho, a view as grava em arquivos temporários (`_salva_arquivo_temporario()`), apagados num `finally`; o limite de 15MB fica no serializer (`validar_tamanho_arquivo_indicador`).
|
||||
- **`POST /api/indicadores-apuracoes/{id}/gerar/`** monta um **ZIP** com um PDF de recibo por colaborador (`indicadores/recibo.py`, `reportlab`) a partir do que já está salvo — não reprocessa as planilhas, reflete qualquer ajuste manual feito na revisão. Recibo é documento interno (só quem tem a permissão do RH acessa/baixa) — sem visão própria do colaborador no Portal nesta v1. Botão "Gerar Recibos" (`indicador-desempenho.js`) abre um modal antes de chamar o endpoint — mesmo componente de busca por nome + filtro por departamento + checklist (com "marcar todos os resultados da busca") do "Ajuste Indicador em Lote", só que já nasce com todo mundo marcado (reproduz o comportamento antigo de "gerar pra todos" sem precisar marcar um por um); desmarcar alguns permite gerar recibo avulso de um colaborador só, de alguns específicos, ou de um departamento inteiro. O endpoint recebe `colaborador_ids` (lista, opcional) e só marca a apuração como `concluida` quando o conjunto pedido bate com **todos** os colaboradores da apuração (sem `colaborador_ids`, ou uma seleção que cobre o total) — gerar um recibo avulso pra conferência não fecha a apuração inteira como se o mês estivesse todo revisado.
|
||||
- **Layout do PDF do recibo** (`indicadores/recibo.py`): o banner "PERCENTUAL DO INDICADOR INDIVIDUAL" sempre mostra o percentual **efetivo/medido** (`calculo.composicao_individual()["total_calculado"]` — a composição dos 3 níveis recalculada na hora, ignorando qualquer ajuste manual), não `colaborador.pct_individual` puro — decisão explícita do usuário: se o RH/Diretoria sobrescreveu o Individual pra 100%, o banner precisa continuar mostrando o que o colaborador de fato atingiu (ex.: 74,29%), não o valor pago. Quando `pct_individual_ajustado_manualmente=True`, uma linha de detalhe abaixo do banner mostra "Percentual Individual Ajustado Pela Direção: **100,00%**." (rótulo renomeado de "ajustado manualmente pelo RH", com o valor ajustado ao lado — antes só dizia que tinha sido ajustado, sem mostrar pra quanto) — os dois números lado a lado deixam claro o que foi medido e o que foi pago. Tabela "Empresas": toda célula (antes só "Empresa" era `Paragraph`, o resto strings soltas) virou `Paragraph` com estilo de alinhamento próprio (`celula_centro`/`celula_direita`/`celula_negrito`/`celula_direita_negrito`, `_estilos()`) — string solta não quebra linha dentro da coluna, e com `ALIGN` à direita/centro um valor mais largo que a coluna (ex.: "Contador (com conciliador)" em Tipo, ou os totais em negrito, mais largos que a mesma string em peso normal) vazava visualmente por cima da célula vizinha em vez de quebrar linha — bug real visto com dados reais (coluna Tipo cobria "Hon. Ajustado"). Coluna "Tipo" ganhou um dicionário de labels curtos só pro PDF (`TIPO_LABEL_CURTO`: "Contador SC"/"Contador CC" em vez de "Contador (sem/com conciliador)" — mesma abreviação já usada informalmente neste documento) porque o label completo não cabia nem quebrando linha numa coluna estreita; a linha de total virou "Total do Indicador" (era "Total Resultado"). Larguras de coluna e padding lateral (`LEFTPADDING`/`RIGHTPADDING`, reduzidos de 6pt padrão do reportlab pra 3pt) ajustados pra caber os maiores valores reais vistos na apuração (ex.: R$ 28.023,16) numa linha só. `_moeda()` usa ` ` (não espaço comum) entre "R$" e o número — com espaço comum, quando o valor não cabia numa linha só, o reportlab quebrava exatamente ali, deixando "R$" sozinho numa linha acima do número; com espaço não separável, o "R$" fica sempre grudado à esquerda do número (mesmo que precise de mais espaço na coluna pra caber tudo numa linha, resolvido junto pelas larguras/padding acima). Rótulo da linha de detalhe é "Percentual individual ajustado pela direção" (minúsculo, só a primeira letra maiúscula — não "Percentual Individual Ajustado Pela Direção").
|
||||
- **`aplicar_em_lote`** (`IndicadorApuracaoRespostaViewSet`, `POST /api/indicadores-apuracoes-respostas/aplicar-em-lote/`) aplica o mesmo valor a várias respostas de critério de uma vez — a "múltipla seleção" pedida pelo usuário na tela de revisão.
|
||||
|
||||
17
portal_api/migrations/0088_conciliacao_sem_arquivo.py
Normal file
17
portal_api/migrations/0088_conciliacao_sem_arquivo.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.7 on 2026-10-01 19:22
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('portal_api', '0087_alter_conciliacaofornecedorvinculohistorico_acao_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='conciliacaofornecedor',
|
||||
name='arquivo',
|
||||
),
|
||||
]
|
||||
25
portal_api/migrations/0089_contabil_indicador_sem_arquivo.py
Normal file
25
portal_api/migrations/0089_contabil_indicador_sem_arquivo.py
Normal file
@ -0,0 +1,25 @@
|
||||
# Generated by Django 6.0.7 on 2026-10-01 20:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('portal_api', '0088_conciliacao_sem_arquivo'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='contabilapuracao',
|
||||
name='arquivo',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='indicadorapuracao',
|
||||
name='planilha_honorarios',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='indicadorapuracao',
|
||||
name='planilha_tareffa',
|
||||
),
|
||||
]
|
||||
@ -1428,16 +1428,8 @@ class IndicadorApuracao(models.Model):
|
||||
|
||||
competencia = models.DateField("Competência")
|
||||
status = models.CharField("Status", max_length=20, choices=STATUS_CHOICES, default=STATUS_REVISAO)
|
||||
planilha_tareffa = models.FileField(
|
||||
"Planilha de Serviços Tareffa",
|
||||
upload_to="indicadores/tareffa/",
|
||||
validators=[validar_tamanho_arquivo_indicador],
|
||||
)
|
||||
planilha_honorarios = models.FileField(
|
||||
"Planilha de Honorários Por Cliente",
|
||||
upload_to="indicadores/honorarios/",
|
||||
validators=[validar_tamanho_arquivo_indicador],
|
||||
)
|
||||
# As duas planilhas (Serviços Tareffa e Honorários) não são gravadas: são
|
||||
# lidas na criação e tudo o que se usa delas fica nos colaboradores.
|
||||
criado_por = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, related_name="apuracoes_indicador")
|
||||
avisos = models.JSONField(
|
||||
"Avisos do processamento",
|
||||
@ -2004,12 +1996,8 @@ class ContabilApuracao(models.Model):
|
||||
competencia = models.DateField("Competência")
|
||||
periodo_inicio = models.DateField("Início do período")
|
||||
periodo_fim = models.DateField("Fim do período")
|
||||
arquivo = models.FileField(
|
||||
"Arquivo do balancete (PDF)",
|
||||
upload_to="contabil/apuracoes/",
|
||||
max_length=255,
|
||||
validators=[validar_tamanho_arquivo_contabil],
|
||||
)
|
||||
# O PDF anexado não é gravado: é lido na criação/reprocessamento e tudo o
|
||||
# que se usa dele fica em contas, linhas e achados.
|
||||
status = models.CharField("Status", max_length=20, choices=STATUS_CHOICES, default=STATUS_REVISAO)
|
||||
criado_por = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, related_name="apuracoes_contabeis")
|
||||
criado_em = models.DateTimeField(auto_now_add=True)
|
||||
@ -2690,6 +2678,8 @@ CONCILIACAO_ARQUIVO_MAX_BYTES = 15 * 1024 * 1024
|
||||
|
||||
|
||||
def validar_tamanho_arquivo_conciliacao(arquivo: File) -> None:
|
||||
# Sem uso desde que o arquivo do razão deixou de ser gravado; fica porque
|
||||
# a migração 0082 a referencia.
|
||||
if arquivo.size > CONCILIACAO_ARQUIVO_MAX_BYTES:
|
||||
raise ValidationError("O arquivo deve ter no máximo 15MB.")
|
||||
|
||||
@ -2706,12 +2696,8 @@ class ConciliacaoFornecedor(models.Model):
|
||||
data_base = models.DateField("Data-base da análise")
|
||||
periodo_inicio = models.DateField("Início do período", null=True, blank=True)
|
||||
periodo_fim = models.DateField("Fim do período", null=True, blank=True)
|
||||
arquivo = models.FileField(
|
||||
"Arquivo do razão",
|
||||
upload_to="conciliacao_fornecedores/",
|
||||
max_length=255,
|
||||
validators=[validar_tamanho_arquivo_conciliacao],
|
||||
)
|
||||
# Só o nome: o arquivo do razão é lido na criação/atualização e não é
|
||||
# gravado (tudo o que se usa dele fica nas contas e lançamentos).
|
||||
nome_arquivo = models.CharField("Nome do arquivo original", max_length=255, blank=True)
|
||||
tolerancia_valor = models.DecimalField("Tolerância (R$)", max_digits=12, decimal_places=2)
|
||||
tolerancia_percentual = models.DecimalField("Tolerância (%)", max_digits=5, decimal_places=2)
|
||||
|
||||
@ -12,6 +12,8 @@ from .empresas_questor import normalizar_codigo_empresa, resolve_nome_empresa
|
||||
from .indicadores import calculo as indicadores_calculo
|
||||
from .nao_conformidades import classificacao as nc_classificacao
|
||||
from .models import (
|
||||
validar_tamanho_arquivo_contabil,
|
||||
validar_tamanho_arquivo_indicador,
|
||||
CONCILIACAO_ARQUIVO_MAX_BYTES,
|
||||
AcessoGeral,
|
||||
AcessoGeralSecao,
|
||||
@ -1698,8 +1700,8 @@ class IndicadorApuracaoCreateSerializer(serializers.Serializer):
|
||||
planilhas mensais (Serviços Tareffa e Honorários Por Cliente)."""
|
||||
|
||||
competencia = serializers.DateField()
|
||||
planilha_tareffa = serializers.FileField()
|
||||
planilha_honorarios = serializers.FileField()
|
||||
planilha_tareffa = serializers.FileField(validators=[validar_tamanho_arquivo_indicador])
|
||||
planilha_honorarios = serializers.FileField(validators=[validar_tamanho_arquivo_indicador])
|
||||
|
||||
|
||||
class IndicadorApuracaoEmpresaSerializer(serializers.ModelSerializer):
|
||||
@ -2248,7 +2250,7 @@ class ContabilApuracaoCreateSerializer(serializers.Serializer):
|
||||
empresa/competência/período são extraídos do próprio arquivo pelo
|
||||
pipeline, não informados pelo usuário."""
|
||||
|
||||
arquivo = serializers.FileField()
|
||||
arquivo = serializers.FileField(validators=[validar_tamanho_arquivo_contabil])
|
||||
|
||||
|
||||
class ContabilContaSerializer(serializers.ModelSerializer):
|
||||
|
||||
@ -2267,24 +2267,22 @@ class IndicadorApuracaoViewSet(viewsets.ModelViewSet):
|
||||
return IndicadorApuracaoListSerializer
|
||||
return IndicadorApuracaoDetailSerializer
|
||||
|
||||
def perform_destroy(self, instance: IndicadorApuracao) -> None:
|
||||
instance.planilha_tareffa.delete(save=False)
|
||||
instance.planilha_honorarios.delete(save=False)
|
||||
instance.delete()
|
||||
|
||||
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||
entrada = IndicadorApuracaoCreateSerializer(data=request.data)
|
||||
entrada.is_valid(raise_exception=True)
|
||||
dados = entrada.validated_data
|
||||
|
||||
apuracao: IndicadorApuracao | None = None
|
||||
# As planilhas não são gravadas: os leitores pedem um caminho, então
|
||||
# vão para arquivos temporários, apagados no fim (deu certo ou não).
|
||||
caminhos_temporarios = [
|
||||
_salva_arquivo_temporario(dados[campo], os.path.splitext(dados[campo].name or "")[1] or ".xlsx")
|
||||
for campo in ("planilha_tareffa", "planilha_honorarios")
|
||||
]
|
||||
try:
|
||||
with transaction.atomic():
|
||||
apuracao = IndicadorApuracao.objects.create(
|
||||
competencia=dados["competencia"],
|
||||
criado_por=request.user,
|
||||
planilha_tareffa=dados["planilha_tareffa"],
|
||||
planilha_honorarios=dados["planilha_honorarios"],
|
||||
)
|
||||
|
||||
# Critérios (e seus critérios automáticos) são agrupados por
|
||||
@ -2311,8 +2309,8 @@ class IndicadorApuracaoViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
resultado = indicadores_pipeline.processa_apuracao(
|
||||
caminho_tareffa=apuracao.planilha_tareffa.path,
|
||||
caminho_honorarios=apuracao.planilha_honorarios.path,
|
||||
caminho_tareffa=caminhos_temporarios[0],
|
||||
caminho_honorarios=caminhos_temporarios[1],
|
||||
gerente_departamento=mapa_gerentes,
|
||||
criterios_automaticos_por_departamento=criterios_automaticos_por_departamento,
|
||||
)
|
||||
@ -2390,12 +2388,8 @@ class IndicadorApuracaoViewSet(viewsets.ModelViewSet):
|
||||
indicadores_calculo.recalcula_colaborador(colaborador)
|
||||
except Exception:
|
||||
# Planilha fora do leiaute esperado, ou qualquer outra falha ao
|
||||
# persistir o resultado — o `transaction.atomic()` já desfez tudo
|
||||
# no banco; só falta apagar os 2 arquivos gravados em MEDIA_ROOT
|
||||
# (upload de FileField não é transacional) pra não deixar lixo.
|
||||
if apuracao is not None:
|
||||
apuracao.planilha_tareffa.delete(save=False)
|
||||
apuracao.planilha_honorarios.delete(save=False)
|
||||
# persistir o resultado: o `transaction.atomic()` já desfez tudo
|
||||
# no banco.
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
@ -2405,6 +2399,12 @@ class IndicadorApuracaoViewSet(viewsets.ModelViewSet):
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
finally:
|
||||
for caminho in caminhos_temporarios:
|
||||
try:
|
||||
os.remove(caminho)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
serializer = IndicadorApuracaoDetailSerializer(apuracao)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
@ -4218,7 +4218,6 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
raise ValidationError(
|
||||
{"detail": "Esta análise já foi concluída — não é mais possível excluí-la."}
|
||||
)
|
||||
instance.arquivo.delete(save=False)
|
||||
instance.delete()
|
||||
|
||||
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||
@ -4242,7 +4241,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
apuracao: ContabilApuracao | None = None
|
||||
# O PDF não é gravado: depois de extraído, nada mais o usa.
|
||||
try:
|
||||
with transaction.atomic():
|
||||
apuracao = ContabilApuracao.objects.create(
|
||||
@ -4252,7 +4251,6 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
competencia=competencia,
|
||||
periodo_inicio=cabecalho.periodo_inicio,
|
||||
periodo_fim=cabecalho.periodo_fim,
|
||||
arquivo=ContentFile(conteudo, name=arquivo.name),
|
||||
criado_por=request.user,
|
||||
analise_vertical_meses=resultado.extracao.meses_analise_vertical,
|
||||
fonte_pdf_atipica=resultado.extracao.fonte_pdf_atipica,
|
||||
@ -4326,8 +4324,6 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
]
|
||||
)
|
||||
except Exception:
|
||||
if apuracao is not None:
|
||||
apuracao.arquivo.delete(save=False)
|
||||
return Response(
|
||||
{"detail": "Não foi possível salvar a análise. Contate o setor de Inovação."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@ -4405,8 +4401,6 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
caminho_arquivo_antigo = apuracao.arquivo.name
|
||||
novo_arquivo_salvo = False
|
||||
try:
|
||||
with transaction.atomic():
|
||||
apuracao.nome_empresa = cabecalho.nome_empresa
|
||||
@ -4416,9 +4410,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
apuracao.analise_vertical_meses = resultado.extracao.meses_analise_vertical
|
||||
apuracao.fonte_pdf_atipica = resultado.extracao.fonte_pdf_atipica
|
||||
apuracao.leiaute = resultado.extracao.leiaute
|
||||
apuracao.arquivo = ContentFile(conteudo, name=arquivo.name)
|
||||
apuracao.save()
|
||||
novo_arquivo_salvo = True
|
||||
|
||||
# Conta/linha que sumiu do arquivo novo vira apontamento na
|
||||
# aba Observações (pedido explícito do usuário): a exclusão
|
||||
@ -4438,21 +4430,12 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
ContabilApuracaoReprocessamento.objects.create(apuracao=apuracao, reprocessado_por=request.user)
|
||||
except Exception:
|
||||
if novo_arquivo_salvo:
|
||||
apuracao.arquivo.delete(save=False)
|
||||
return Response(
|
||||
{"detail": "Não foi possível reprocessar a análise. Contate o setor de Inovação."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Só chega aqui se a transação inteira deu certo — o arquivo antigo
|
||||
# não é mais referenciado por nenhuma linha do banco, então é seguro
|
||||
# apagar seu conteúdo físico agora (não é transacional, por isso o
|
||||
# cuidado de só apagar depois do commit).
|
||||
if caminho_arquivo_antigo and caminho_arquivo_antigo != apuracao.arquivo.name:
|
||||
apuracao.arquivo.storage.delete(caminho_arquivo_antigo)
|
||||
|
||||
apuracao = ContabilApuracao.objects.prefetch_related(
|
||||
apuracao =ContabilApuracao.objects.prefetch_related(
|
||||
"contas", "linhas_dre", "linhas_analise_vertical", "achados__conta"
|
||||
).get(pk=apuracao.pk)
|
||||
return Response(ContabilApuracaoDetailSerializer(apuracao).data)
|
||||
@ -6105,10 +6088,9 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
# Nome resolvido antes da transação: é uma consulta a banco externo.
|
||||
nome_empresa = resolve_nome_empresa(dados["codigo_empresa"]) or ""
|
||||
conciliacao: ConciliacaoFornecedor | None = None
|
||||
try:
|
||||
# O arquivo não é gravado: depois de lido, nada mais o usa.
|
||||
with transaction.atomic():
|
||||
conciliacao = ConciliacaoFornecedor(
|
||||
conciliacao = ConciliacaoFornecedor.objects.create(
|
||||
codigo_empresa=dados["codigo_empresa"],
|
||||
nome_empresa=nome_empresa,
|
||||
data_base=timezone.localdate(),
|
||||
@ -6119,16 +6101,9 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
|
||||
tolerancia_percentual=tolerancia.percentual,
|
||||
criado_por=request.user,
|
||||
)
|
||||
conciliacao.arquivo.save(arquivo.name, ContentFile(conteudo), save=False)
|
||||
conciliacao.save()
|
||||
_conciliacao_grava_contas(conciliacao, razao)
|
||||
_conciliacao_valida_conciliadas(conciliacao)
|
||||
_conciliacao_atualiza_resumo(conciliacao)
|
||||
except Exception:
|
||||
# upload de FileField não é transacional
|
||||
if conciliacao is not None and conciliacao.arquivo:
|
||||
conciliacao.arquivo.delete(save=False)
|
||||
raise
|
||||
return Response(ConciliacaoFornecedorListSerializer(conciliacao).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="nome-empresa")
|
||||
@ -6168,18 +6143,12 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
|
||||
raise ValidationError({"arquivo": str(exc)})
|
||||
_conciliacao_aplica_periodo_fim(razao, entrada.validated_data["periodo_fim"], conciliacao.periodo_fim)
|
||||
|
||||
storage = conciliacao.arquivo.storage
|
||||
arquivo_antigo = conciliacao.arquivo.name
|
||||
arquivo_novo: str | None = None
|
||||
try:
|
||||
with transaction.atomic():
|
||||
conciliacao = ConciliacaoFornecedor.objects.select_for_update().get(pk=conciliacao.pk)
|
||||
periodo_anterior = (conciliacao.periodo_inicio, conciliacao.periodo_fim)
|
||||
contagem = _conciliacao_aplica_atualizacao(conciliacao, razao, request.user)
|
||||
conciliacao.arquivo.save(arquivo.name, ContentFile(conteudo), save=False)
|
||||
arquivo_novo = conciliacao.arquivo.name
|
||||
conciliacao.nome_arquivo = arquivo.name[:255]
|
||||
conciliacao.save(update_fields=["arquivo", "nome_arquivo"])
|
||||
conciliacao.save(update_fields=["nome_arquivo"])
|
||||
ConciliacaoFornecedorAtualizacao.objects.create(
|
||||
conciliacao=conciliacao,
|
||||
nome_arquivo=arquivo.name[:255],
|
||||
@ -6190,13 +6159,6 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
|
||||
resumo=contagem,
|
||||
usuario=request.user,
|
||||
)
|
||||
except Exception:
|
||||
# upload de FileField não é transacional
|
||||
if arquivo_novo:
|
||||
storage.delete(arquivo_novo)
|
||||
raise
|
||||
if arquivo_antigo and arquivo_antigo != arquivo_novo:
|
||||
storage.delete(arquivo_antigo)
|
||||
conciliacao = self.get_queryset().get(pk=conciliacao.pk)
|
||||
return Response({"conciliacao": ConciliacaoFornecedorListSerializer(conciliacao).data, "atualizacao": contagem})
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user