diff --git a/README.md b/README.md index d271dc4..3386ad8 100644 --- a/README.md +++ b/README.md @@ -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). +`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). ## Login e permissões diff --git a/app/db/clientes.py b/app/db/clientes.py index 139e221..6899786 100644 --- a/app/db/clientes.py +++ b/app/db/clientes.py @@ -2,6 +2,21 @@ CRUD completo de CLIENTE. Única tabela cadastral (ID autoincrement é a PK técnica; CODIGO é o identificador de negócio usado pelo resto do sistema — 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 @@ -11,7 +26,7 @@ CAMPOS_CLIENTE = [ "CODIGO", "CNPJ", "RAZAO", "FANTASIA", "IE", "ENDERECO", "CIDADE", "BAIRRO", "UF", "CONTATO", "TELEFONE", "CELULAR", "CEP", "IMPLANTACAO", "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]) @@ -42,26 +57,31 @@ def listar_clientes( offset: int = 0, ordenar_por: str = ORDENACAO_PADRAO, direcao: str = DIRECAO_PADRAO, + filtro_ativo: int | None = 1, ) -> list[dict]: coluna = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_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() try: 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 = ( - f"SELECT {_COLUNAS_SELECT} FROM CLIENTE " - f"WHERE {coluna} LIKE %s 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), - ) + sql = ( + f"SELECT {_COLUNAS_SELECT} FROM CLIENTE {where_sql}" + f"ORDER BY {coluna} {direcao_sql} LIMIT %s OFFSET %s" + ) + cur.execute(sql, [*params, limite, offset]) return cur.fetchall() finally: conn.close() @@ -91,15 +111,22 @@ def proximo_codigo_sugerido() -> str: 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]) + 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() try: with conn.cursor() as cur: - if busca: - cur.execute(f"SELECT COUNT(*) AS total FROM CLIENTE WHERE {coluna} LIKE %s", (f"%{busca}%",)) - else: - cur.execute("SELECT COUNT(*) AS total FROM CLIENTE") + cur.execute(f"SELECT COUNT(*) AS total FROM CLIENTE {where_sql}", params) return cur.fetchone()["total"] finally: conn.close() diff --git a/tests/test_db_clientes.py b/tests/test_db_clientes.py index b042b79..e52d499 100644 --- a/tests/test_db_clientes.py +++ b/tests/test_db_clientes.py @@ -32,6 +32,7 @@ CLIENTE_TESTE = { "OBS": "Criado por teste automatizado — apagar se sobrar.", "VENCIMENTO_CONTRATO": "2027-01-01", "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 +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): conn = get_connection() try: