feat: log de acoes/envios, ordenacao/filtro, codigo sequencial, campos opcionais
- Tabela LOG_PAINEL (nova, ja criada no banco de dev) + app/db/log.py: toda criacao/edicao/exclusao de cliente e contato fica registrada (usuario, acao, sucesso/erro, detalhe). Tela /log mostra esse historico. - app/n8n_client.py: leitura opcional (N8N_API_URL/N8N_API_KEY) da Data Table pagamentos_processados do n8n, mostrando os envios automaticos de chave na mesma tela /log. Degrada de forma segura (lista vazia) se nao configurado ou se a chamada falhar. - Lista de clientes ganha ordenacao configuravel (codigo/razao/ fantasia/CNPJ) via whitelist, alem da busca que ja existia. - CODIGO duplicado agora BLOQUEIA de verdade na criacao/edicao (sem bypass) — antes so avisava com opcao de confirmar mesmo assim. - Formulario de novo cliente sugere o proximo CODIGO sequencial (editavel), ignorando a faixa reservada para dados de teste (>= 900000) para nao ser contaminado por sobra de teste no banco. - IMPLANTACAO/MENSALIDADE/JUROS_MORA/JUROS_MULTA/DATA_CONTRATO/ VENCIMENTO_CONTRATO deixam de ser obrigatorios — em branco, gravam default (0,00 / hoje) em vez de bloquear o cadastro. Só CODIGO/CNPJ/ RAZAO/FANTASIA continuam obrigatorios. - Limpeza: removidas 10 linhas de teste vazadas (CODIGO 999902) que ficaram no banco de dev por sessoes de teste interrompidas. 69/69 testes passando (16 novos).
This commit is contained in:
+41
-3
@@ -16,12 +16,25 @@ CAMPOS_CLIENTE = [
|
||||
|
||||
_COLUNAS_SELECT = ", ".join(["ID", *CAMPOS_CLIENTE])
|
||||
|
||||
# Whitelist de colunas aceitas para ordenar a listagem — nunca interpolar
|
||||
# o valor de "ordenar_por" vindo da URL direto no SQL sem passar por aqui.
|
||||
ORDENACOES_VALIDAS = {
|
||||
"codigo": "CODIGO",
|
||||
"razao": "RAZAO",
|
||||
"fantasia": "FANTASIA",
|
||||
"cnpj": "CNPJ",
|
||||
}
|
||||
ORDENACAO_PADRAO = "razao"
|
||||
|
||||
|
||||
class ClienteComDependenciasError(Exception):
|
||||
"""Levantada ao tentar excluir um CLIENTE que ainda tem CLIENTE_CONTATO."""
|
||||
|
||||
|
||||
def listar_clientes(busca: str | None = None, limite: int = 100, offset: int = 0) -> list[dict]:
|
||||
def listar_clientes(
|
||||
busca: str | None = None, limite: int = 100, offset: int = 0, ordenar_por: str = ORDENACAO_PADRAO
|
||||
) -> list[dict]:
|
||||
coluna_ordenacao = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_PADRAO])
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
@@ -29,19 +42,44 @@ def listar_clientes(busca: str | None = None, limite: int = 100, offset: int = 0
|
||||
sql = (
|
||||
f"SELECT {_COLUNAS_SELECT} FROM CLIENTE "
|
||||
"WHERE CODIGO LIKE %s OR CNPJ LIKE %s OR RAZAO LIKE %s OR FANTASIA LIKE %s "
|
||||
"ORDER BY RAZAO LIMIT %s OFFSET %s"
|
||||
f"ORDER BY {coluna_ordenacao} LIMIT %s OFFSET %s"
|
||||
)
|
||||
termo = f"%{busca}%"
|
||||
cur.execute(sql, (termo, termo, termo, termo, limite, offset))
|
||||
else:
|
||||
cur.execute(
|
||||
f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY RAZAO LIMIT %s OFFSET %s", (limite, offset)
|
||||
f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY {coluna_ordenacao} LIMIT %s OFFSET %s",
|
||||
(limite, offset),
|
||||
)
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def proximo_codigo_sugerido() -> str:
|
||||
"""
|
||||
Sugere o próximo CODIGO sequencial (maior CODIGO numérico + 1, com zeros
|
||||
à esquerda até 6 dígitos). É só uma sugestão pré-preenchida no formulário
|
||||
de novo cliente — o campo continua editável, e codigo_em_uso() ainda
|
||||
confere unicidade de verdade antes de gravar.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CODIGO >= 900000 é a faixa reservada para dados de teste deste
|
||||
# painel (ver tests/test_db_clientes.py) — excluída aqui para a
|
||||
# sugestão não disparar lá em cima se sobrar alguma linha de teste.
|
||||
cur.execute(
|
||||
"SELECT MAX(CAST(CODIGO AS UNSIGNED)) AS maior FROM CLIENTE "
|
||||
"WHERE CODIGO REGEXP '^[0-9]+$' AND CAST(CODIGO AS UNSIGNED) < 900000"
|
||||
)
|
||||
maior = cur.fetchone()["maior"]
|
||||
finally:
|
||||
conn.close()
|
||||
proximo = (maior or 0) + 1
|
||||
return str(proximo).zfill(6)
|
||||
|
||||
|
||||
def contar_clientes(busca: str | None = None) -> int:
|
||||
conn = get_connection()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Log de ações do painel (LOG_PAINEL) — uma linha por ação que muda estado
|
||||
(criar/editar/excluir cliente, adicionar/editar/excluir contato), sucesso
|
||||
ou erro. Escrita feita pelos routers via `registrar(...)`, nunca
|
||||
sobrescrita/editada depois — é histórico, não estado.
|
||||
|
||||
A tabela já existe no banco de dev. Em qualquer outro ambiente (produção),
|
||||
crie-a primeiro com:
|
||||
|
||||
CREATE TABLE LOG_PAINEL (
|
||||
ID INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
CRIADO_EM DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
USUARIO VARCHAR(60) NOT NULL,
|
||||
ACAO VARCHAR(40) NOT NULL,
|
||||
ENTIDADE VARCHAR(20) NOT NULL,
|
||||
ENTIDADE_ID VARCHAR(20) DEFAULT NULL,
|
||||
SUCESSO TINYINT(1) NOT NULL,
|
||||
DETALHE VARCHAR(500) DEFAULT NULL,
|
||||
PRIMARY KEY (ID),
|
||||
KEY IDX_LOG_PAINEL_CRIADO_EM (CRIADO_EM)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
|
||||
"""
|
||||
from .connection import get_connection
|
||||
|
||||
|
||||
def registrar(
|
||||
usuario: str,
|
||||
acao: str,
|
||||
entidade: str,
|
||||
entidade_id: str | None,
|
||||
sucesso: bool,
|
||||
detalhe: str | None = None,
|
||||
) -> None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO LOG_PAINEL (USUARIO, ACAO, ENTIDADE, ENTIDADE_ID, SUCESSO, DETALHE) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s)",
|
||||
(usuario, acao, entidade, entidade_id, 1 if sucesso else 0, detalhe),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def listar(limite: int = 100, offset: int = 0) -> list[dict]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT ID, CRIADO_EM, USUARIO, ACAO, ENTIDADE, ENTIDADE_ID, SUCESSO, DETALHE "
|
||||
"FROM LOG_PAINEL ORDER BY CRIADO_EM DESC, ID DESC LIMIT %s OFFSET %s",
|
||||
(limite, offset),
|
||||
)
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def contar() -> int:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) AS total FROM LOG_PAINEL")
|
||||
return cur.fetchone()["total"]
|
||||
finally:
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user