feat: coluna ATIVO em CLIENTE e filtro na camada de banco
This commit is contained in:
@@ -6,6 +6,8 @@ Proteções: login por sessão em toda rota (cookie assinado — ver seção "Lo
|
|||||||
|
|
||||||
`CODIGO` de cliente não aceita duplicado (bloqueio duro, sem bypass) e o formulário de novo cliente já sugere o próximo código sequencial (editável). Só `CODIGO`, `CNPJ`, `RAZAO` e `FANTASIA` são obrigatórios — os campos de contrato (implantação, mensalidade, juros, datas) podem ficar em branco e recebem default (0,00 / data de hoje).
|
`CODIGO` de cliente não aceita duplicado (bloqueio duro, sem bypass) e o formulário de novo cliente já sugere o próximo código sequencial (editável). Só `CODIGO`, `CNPJ`, `RAZAO` e `FANTASIA` são obrigatórios — os campos de contrato (implantação, mensalidade, juros, datas) podem ficar em branco e recebem default (0,00 / data de hoje).
|
||||||
|
|
||||||
|
`CLIENTE` tem uma coluna `ATIVO` (criada no banco de dev — **precisa ser criada manualmente em produção também**, `ALTER TABLE` completo no início de `app/db/clientes.py`) que controla o filtro de status da listagem (Ativos/Inativos/Todos, padrão Ativos). Sem essa coluna, a listagem quebra com erro 500. O `DEFAULT 1` do `ALTER TABLE` é intencional: todo cliente cadastrado antes da migração nasce como Ativo.
|
||||||
|
|
||||||
A rota `/log` mostra o histórico de ações do painel (tabela `LOG_PAINEL`, criada no banco de dev — **precisa ser criada manualmente em produção também**, DDL no início de `app/db/log.py`) e, se `N8N_API_URL`/`N8N_API_KEY` estiverem configuradas (`.env.example`), os últimos envios automáticos de chave via WhatsApp (lidos da Data Table `pagamentos_processados` do n8n, somente leitura).
|
A rota `/log` mostra o histórico de ações do painel (tabela `LOG_PAINEL`, criada no banco de dev — **precisa ser criada manualmente em produção também**, DDL no início de `app/db/log.py`) e, se `N8N_API_URL`/`N8N_API_KEY` estiverem configuradas (`.env.example`), os últimos envios automáticos de chave via WhatsApp (lidos da Data Table `pagamentos_processados` do n8n, somente leitura).
|
||||||
|
|
||||||
## Login e permissões
|
## Login e permissões
|
||||||
|
|||||||
+45
-18
@@ -2,6 +2,21 @@
|
|||||||
CRUD completo de CLIENTE. Única tabela cadastral (ID autoincrement é a PK
|
CRUD completo de CLIENTE. Única tabela cadastral (ID autoincrement é a PK
|
||||||
técnica; CODIGO é o identificador de negócio usado pelo resto do sistema —
|
técnica; CODIGO é o identificador de negócio usado pelo resto do sistema —
|
||||||
ver licenciamento-n8n/docs/MAPEAMENTO_BANCO.md).
|
ver licenciamento-n8n/docs/MAPEAMENTO_BANCO.md).
|
||||||
|
|
||||||
|
CLIENTE.ATIVO (TINYINT(1) NOT NULL DEFAULT 1) controla se o cliente
|
||||||
|
aparece no filtro padrão da listagem ("Ativos"). Se CLIENTE já existir de
|
||||||
|
uma instalação anterior sem essa coluna, rode:
|
||||||
|
|
||||||
|
ALTER TABLE CLIENTE
|
||||||
|
ADD COLUMN ATIVO TINYINT(1) NOT NULL DEFAULT 1
|
||||||
|
AFTER DIA_VENCIMENTO;
|
||||||
|
|
||||||
|
DEFAULT 1 é intencional: todo cliente cadastrado antes da migração nasce
|
||||||
|
como Ativo — decisão do projeto (quem administra desativa manualmente os
|
||||||
|
que sabe que não são mais, ver
|
||||||
|
docs/superpowers/specs/2026-08-20-status-ativo-clientes-design.md).
|
||||||
|
Cadastros/edições novos sempre informam ATIVO explicitamente (vindo do
|
||||||
|
formulário), nunca dependem desse default.
|
||||||
"""
|
"""
|
||||||
import pymysql
|
import pymysql
|
||||||
|
|
||||||
@@ -11,7 +26,7 @@ CAMPOS_CLIENTE = [
|
|||||||
"CODIGO", "CNPJ", "RAZAO", "FANTASIA", "IE", "ENDERECO", "CIDADE",
|
"CODIGO", "CNPJ", "RAZAO", "FANTASIA", "IE", "ENDERECO", "CIDADE",
|
||||||
"BAIRRO", "UF", "CONTATO", "TELEFONE", "CELULAR", "CEP", "IMPLANTACAO",
|
"BAIRRO", "UF", "CONTATO", "TELEFONE", "CELULAR", "CEP", "IMPLANTACAO",
|
||||||
"MENSALIDADE", "JUROS_MORA", "JUROS_MULTA", "DATA_CONTRATO", "EMAIL",
|
"MENSALIDADE", "JUROS_MORA", "JUROS_MULTA", "DATA_CONTRATO", "EMAIL",
|
||||||
"OBS", "VENCIMENTO_CONTRATO", "DIA_VENCIMENTO",
|
"OBS", "VENCIMENTO_CONTRATO", "DIA_VENCIMENTO", "ATIVO",
|
||||||
]
|
]
|
||||||
|
|
||||||
_COLUNAS_SELECT = ", ".join(["ID", *CAMPOS_CLIENTE])
|
_COLUNAS_SELECT = ", ".join(["ID", *CAMPOS_CLIENTE])
|
||||||
@@ -42,26 +57,31 @@ def listar_clientes(
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
ordenar_por: str = ORDENACAO_PADRAO,
|
ordenar_por: str = ORDENACAO_PADRAO,
|
||||||
direcao: str = DIRECAO_PADRAO,
|
direcao: str = DIRECAO_PADRAO,
|
||||||
|
filtro_ativo: int | None = 1,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
coluna = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_PADRAO])
|
coluna = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_PADRAO])
|
||||||
direcao_sql = DIRECOES_VALIDAS.get(direcao, DIRECOES_VALIDAS[DIRECAO_PADRAO])
|
direcao_sql = DIRECOES_VALIDAS.get(direcao, DIRECOES_VALIDAS[DIRECAO_PADRAO])
|
||||||
|
# A busca é restrita à mesma coluna escolhida em "ordenar_por" — selecionar
|
||||||
|
# "Código" busca só em CODIGO, não em todos os campos de uma vez.
|
||||||
|
# filtro_ativo: 1 = só ativos, 0 = só inativos, None = sem filtro (todos).
|
||||||
|
condicoes = []
|
||||||
|
params: list = []
|
||||||
|
if busca:
|
||||||
|
condicoes.append(f"{coluna} LIKE %s")
|
||||||
|
params.append(f"%{busca}%")
|
||||||
|
if filtro_ativo is not None:
|
||||||
|
condicoes.append("ATIVO = %s")
|
||||||
|
params.append(filtro_ativo)
|
||||||
|
where_sql = f"WHERE {' AND '.join(condicoes)} " if condicoes else ""
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
if busca:
|
|
||||||
# A busca é restrita à mesma coluna escolhida em "ordenar_por"
|
|
||||||
# — selecionar "Código" busca só em CODIGO, não em todos os
|
|
||||||
# campos de uma vez.
|
|
||||||
sql = (
|
sql = (
|
||||||
f"SELECT {_COLUNAS_SELECT} FROM CLIENTE "
|
f"SELECT {_COLUNAS_SELECT} FROM CLIENTE {where_sql}"
|
||||||
f"WHERE {coluna} LIKE %s ORDER BY {coluna} {direcao_sql} LIMIT %s OFFSET %s"
|
f"ORDER BY {coluna} {direcao_sql} LIMIT %s OFFSET %s"
|
||||||
)
|
|
||||||
cur.execute(sql, (f"%{busca}%", limite, offset))
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY {coluna} {direcao_sql} LIMIT %s OFFSET %s",
|
|
||||||
(limite, offset),
|
|
||||||
)
|
)
|
||||||
|
cur.execute(sql, [*params, limite, offset])
|
||||||
return cur.fetchall()
|
return cur.fetchall()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -91,15 +111,22 @@ def proximo_codigo_sugerido() -> str:
|
|||||||
return str(proximo).zfill(6)
|
return str(proximo).zfill(6)
|
||||||
|
|
||||||
|
|
||||||
def contar_clientes(busca: str | None = None, ordenar_por: str = ORDENACAO_PADRAO) -> int:
|
def contar_clientes(busca: str | None = None, ordenar_por: str = ORDENACAO_PADRAO, filtro_ativo: int | None = 1) -> int:
|
||||||
coluna = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_PADRAO])
|
coluna = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_PADRAO])
|
||||||
|
condicoes = []
|
||||||
|
params: list = []
|
||||||
|
if busca:
|
||||||
|
condicoes.append(f"{coluna} LIKE %s")
|
||||||
|
params.append(f"%{busca}%")
|
||||||
|
if filtro_ativo is not None:
|
||||||
|
condicoes.append("ATIVO = %s")
|
||||||
|
params.append(filtro_ativo)
|
||||||
|
where_sql = f"WHERE {' AND '.join(condicoes)}" if condicoes else ""
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
if busca:
|
cur.execute(f"SELECT COUNT(*) AS total FROM CLIENTE {where_sql}", params)
|
||||||
cur.execute(f"SELECT COUNT(*) AS total FROM CLIENTE WHERE {coluna} LIKE %s", (f"%{busca}%",))
|
|
||||||
else:
|
|
||||||
cur.execute("SELECT COUNT(*) AS total FROM CLIENTE")
|
|
||||||
return cur.fetchone()["total"]
|
return cur.fetchone()["total"]
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ CLIENTE_TESTE = {
|
|||||||
"OBS": "Criado por teste automatizado — apagar se sobrar.",
|
"OBS": "Criado por teste automatizado — apagar se sobrar.",
|
||||||
"VENCIMENTO_CONTRATO": "2027-01-01",
|
"VENCIMENTO_CONTRATO": "2027-01-01",
|
||||||
"DIA_VENCIMENTO": "10",
|
"DIA_VENCIMENTO": "10",
|
||||||
|
"ATIVO": "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +164,54 @@ def test_proximo_codigo_sugerido_ignora_faixa_de_teste(cliente_id):
|
|||||||
assert db_clientes.codigo_em_uso(sugerido) is False
|
assert db_clientes.codigo_em_uso(sugerido) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_listar_clientes_filtro_ativo_so_ativos(cliente_id):
|
||||||
|
inativo_id = db_clientes.criar_cliente(dict(CLIENTE_TESTE, CODIGO="999902", ATIVO="0"))
|
||||||
|
try:
|
||||||
|
resultado = db_clientes.listar_clientes(busca="TESTE PAINEL", filtro_ativo=1)
|
||||||
|
codigos = [c["CODIGO"] for c in resultado]
|
||||||
|
assert "999901" in codigos
|
||||||
|
assert "999902" not in codigos
|
||||||
|
finally:
|
||||||
|
_limpar("999902")
|
||||||
|
|
||||||
|
|
||||||
|
def test_listar_clientes_filtro_inativo_so_inativos(cliente_id):
|
||||||
|
inativo_id = db_clientes.criar_cliente(dict(CLIENTE_TESTE, CODIGO="999903", ATIVO="0"))
|
||||||
|
try:
|
||||||
|
resultado = db_clientes.listar_clientes(busca="TESTE PAINEL", filtro_ativo=0)
|
||||||
|
codigos = [c["CODIGO"] for c in resultado]
|
||||||
|
assert "999901" not in codigos
|
||||||
|
assert "999903" in codigos
|
||||||
|
finally:
|
||||||
|
_limpar("999903")
|
||||||
|
|
||||||
|
|
||||||
|
def test_listar_clientes_filtro_none_traz_todos(cliente_id):
|
||||||
|
inativo_id = db_clientes.criar_cliente(dict(CLIENTE_TESTE, CODIGO="999904", ATIVO="0"))
|
||||||
|
try:
|
||||||
|
resultado = db_clientes.listar_clientes(busca="TESTE PAINEL", filtro_ativo=None)
|
||||||
|
codigos = [c["CODIGO"] for c in resultado]
|
||||||
|
assert "999901" in codigos
|
||||||
|
assert "999904" in codigos
|
||||||
|
finally:
|
||||||
|
_limpar("999904")
|
||||||
|
|
||||||
|
|
||||||
|
def test_contar_clientes_respeita_filtro_ativo(cliente_id):
|
||||||
|
inativo_id = db_clientes.criar_cliente(dict(CLIENTE_TESTE, CODIGO="999907", ATIVO="0"))
|
||||||
|
try:
|
||||||
|
assert db_clientes.contar_clientes(busca="TESTE PAINEL", filtro_ativo=1) == 1
|
||||||
|
assert db_clientes.contar_clientes(busca="TESTE PAINEL", filtro_ativo=0) == 1
|
||||||
|
assert db_clientes.contar_clientes(busca="TESTE PAINEL", filtro_ativo=None) == 2
|
||||||
|
finally:
|
||||||
|
_limpar("999907")
|
||||||
|
|
||||||
|
|
||||||
|
def test_criar_cliente_grava_ativo(cliente_id):
|
||||||
|
cliente = db_clientes.obter_cliente(cliente_id)
|
||||||
|
assert cliente["ATIVO"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_excluir_cliente_com_contato_bloqueia(cliente_id):
|
def test_excluir_cliente_com_contato_bloqueia(cliente_id):
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user