From c7f2c5f92b5cc030306f6a5ee616e8021ab3892e Mon Sep 17 00:00:00 2001
From: Gustavo
Date: Thu, 13 Aug 2026 10:07:15 -0300
Subject: [PATCH] feat: rotas e telas de CLIENTE (listar, criar, editar,
detalhe, excluir)
---
app/routers/__init__.py | 1 +
app/routers/clientes.py | 137 +++++++++++++++++++++++++++++
app/templates/base.html | 35 ++++++++
app/templates/cliente_detalhe.html | 31 +++++++
app/templates/cliente_form.html | 26 ++++++
app/templates/clientes_lista.html | 23 +++++
tests/test_router_clientes.py | 81 +++++++++++++++++
7 files changed, 334 insertions(+)
create mode 100644 app/routers/__init__.py
create mode 100644 app/routers/clientes.py
create mode 100644 app/templates/base.html
create mode 100644 app/templates/cliente_detalhe.html
create mode 100644 app/templates/cliente_form.html
create mode 100644 app/templates/clientes_lista.html
create mode 100644 tests/test_router_clientes.py
diff --git a/app/routers/__init__.py b/app/routers/__init__.py
new file mode 100644
index 0000000..567b54d
--- /dev/null
+++ b/app/routers/__init__.py
@@ -0,0 +1 @@
+# app/routers/__init__.py
diff --git a/app/routers/clientes.py b/app/routers/clientes.py
new file mode 100644
index 0000000..486e148
--- /dev/null
+++ b/app/routers/clientes.py
@@ -0,0 +1,137 @@
+# app/routers/clientes.py
+"""
+Rotas de gestão de CLIENTE: listar/buscar, criar, editar, ver detalhe
+(contatos + status de Clientes_Chaves), excluir.
+"""
+from typing import Optional
+from urllib.parse import urlencode
+
+from fastapi import APIRouter, Depends, Form, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.templating import Jinja2Templates
+
+from ..auth import exigir_login
+from ..db import chaves as db_chaves
+from ..db import clientes as db_clientes
+from ..db import contatos as db_contatos
+from ..normalize import normalizar_documento, validar_dv_cpf, validar_dv_cnpj
+
+router = APIRouter(prefix="/clientes")
+templates = Jinja2Templates(directory="app/templates")
+
+
+def _redirect_com_mensagem(url: str, mensagem: str, tipo: str = "success") -> RedirectResponse:
+ separador = "&" if "?" in url else "?"
+ return RedirectResponse(url + separador + urlencode({"mensagem": mensagem, "mensagem_tipo": tipo}), status_code=303)
+
+
+def _ler_form_cliente(form: dict) -> dict:
+ return {campo: (form.get(campo) or "").strip() for campo in db_clientes.CAMPOS_CLIENTE}
+
+
+@router.get("", response_class=HTMLResponse)
+def listar(
+ request: Request,
+ busca: Optional[str] = None,
+ mensagem: Optional[str] = None,
+ mensagem_tipo: Optional[str] = None,
+ usuario: str = Depends(exigir_login),
+):
+ clientes = db_clientes.listar_clientes(busca=busca or None)
+ return templates.TemplateResponse(
+ "clientes_lista.html",
+ {"request": request, "clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo},
+ )
+
+
+@router.get("/novo", response_class=HTMLResponse)
+def form_novo(request: Request, usuario: str = Depends(exigir_login)):
+ return templates.TemplateResponse(
+ "cliente_form.html",
+ {"request": request, "cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE},
+ )
+
+
+@router.post("/novo")
+async def criar(request: Request, usuario: str = Depends(exigir_login)):
+ form = dict((await request.form()))
+ dados = _ler_form_cliente(form)
+ confirmar_duplicado = form.get("confirmar_codigo_duplicado") == "1"
+
+ if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"]):
+ return templates.TemplateResponse(
+ "cliente_form.html",
+ {"request": request, "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True},
+ )
+
+ _, formato_valido, tipo = normalizar_documento(dados["CNPJ"])
+ dv_invalido = False
+ if formato_valido:
+ dv_invalido = not (validar_dv_cpf(dados["CNPJ"]) if tipo == "CPF" else validar_dv_cnpj(dados["CNPJ"]))
+ if dv_invalido and form.get("confirmar_dv_invalido") != "1":
+ return templates.TemplateResponse(
+ "cliente_form.html",
+ {"request": request, "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_dv_invalido": True},
+ )
+
+ novo_id = db_clientes.criar_cliente(dados)
+ return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.")
+
+
+@router.get("/{cliente_id}", response_class=HTMLResponse)
+def detalhe(
+ request: Request,
+ cliente_id: int,
+ mensagem: Optional[str] = None,
+ mensagem_tipo: Optional[str] = None,
+ usuario: str = Depends(exigir_login),
+):
+ cliente = db_clientes.obter_cliente(cliente_id)
+ if cliente is None:
+ return _redirect_com_mensagem("/clientes", "Cliente não encontrado.", "danger")
+
+ contatos = db_contatos.listar_contatos_por_cliente(cliente_id)
+ status = db_chaves.status_chaves(cliente["CODIGO"])
+ return templates.TemplateResponse(
+ "cliente_detalhe.html",
+ {
+ "request": request, "cliente": cliente, "contatos": contatos, "status_chaves": status,
+ "mensagem": mensagem, "mensagem_tipo": mensagem_tipo,
+ },
+ )
+
+
+@router.get("/{cliente_id}/editar", response_class=HTMLResponse)
+def form_editar(request: Request, cliente_id: int, usuario: str = Depends(exigir_login)):
+ cliente = db_clientes.obter_cliente(cliente_id)
+ if cliente is None:
+ return _redirect_com_mensagem("/clientes", "Cliente não encontrado.", "danger")
+ return templates.TemplateResponse(
+ "cliente_form.html",
+ {"request": request, "cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE},
+ )
+
+
+@router.post("/{cliente_id}/editar")
+async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigir_login)):
+ form = dict((await request.form()))
+ dados = _ler_form_cliente(form)
+ confirmar_duplicado = form.get("confirmar_codigo_duplicado") == "1"
+
+ if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"], ignorar_id=cliente_id):
+ return templates.TemplateResponse(
+ "cliente_form.html",
+ {"request": request, "cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True},
+ )
+
+ db_clientes.atualizar_cliente(cliente_id, dados)
+ return _redirect_com_mensagem(f"/clientes/{cliente_id}", "Cliente atualizado.")
+
+
+@router.post("/{cliente_id}/excluir")
+def excluir(cliente_id: int, usuario: str = Depends(exigir_login)):
+ try:
+ db_clientes.excluir_cliente(cliente_id)
+ except db_clientes.ClienteComDependenciasError as exc:
+ return _redirect_com_mensagem(f"/clientes/{cliente_id}", str(exc), "danger")
+ return _redirect_com_mensagem("/clientes", "Cliente excluído.")
diff --git a/app/templates/base.html b/app/templates/base.html
new file mode 100644
index 0000000..aee3555
--- /dev/null
+++ b/app/templates/base.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ {% block titulo %}Painel de Clientes{% endblock %}
+
+
+
+
+
+ {% if mensagem %}{{ mensagem }}
{% endif %}
+ {% block conteudo %}{% endblock %}
+
+
+
diff --git a/app/templates/cliente_detalhe.html b/app/templates/cliente_detalhe.html
new file mode 100644
index 0000000..6aae177
--- /dev/null
+++ b/app/templates/cliente_detalhe.html
@@ -0,0 +1,31 @@
+
+{% extends "base.html" %}
+{% block titulo %}{{ cliente.RAZAO }}{% endblock %}
+{% block conteudo %}
+ {{ cliente.FANTASIA }} ({{ cliente.CODIGO }})
+ {{ cliente.RAZAO }} — {{ cliente.CNPJ }}
+
+ Editar
+
+
+
+ Contatos
+
+ | Nome | Telefone | E-mail |
+ {% for ct in contatos %}
+ | {{ ct.NOME }} | {{ ct.TELEFONE }} | {{ ct.EMAIL or "" }} |
+ {% endfor %}
+
+
+ Status em Clientes_Chaves
+ {% if status_chaves %}
+
+ {% for mes in status_chaves.meses %}| {{ mes }} | {% endfor %}
+ {% for mes, valor in status_chaves.meses.items() %}| {{ "✓" if valor else "—" }} | {% endfor %}
+
+ {% else %}
+ Nenhuma linha em Clientes_Chaves para o código {{ cliente.CODIGO }}.
+ {% endif %}
+{% endblock %}
diff --git a/app/templates/cliente_form.html b/app/templates/cliente_form.html
new file mode 100644
index 0000000..015cb3a
--- /dev/null
+++ b/app/templates/cliente_form.html
@@ -0,0 +1,26 @@
+
+{% extends "base.html" %}
+{% block titulo %}{{ "Editar" if cliente else "Novo" }} cliente{% endblock %}
+{% block conteudo %}
+ {{ "Editar" if cliente else "Novo" }} cliente
+ {% if aviso_codigo_duplicado %}
+
+ O código {{ dados.CODIGO }} já está em uso por outro cliente.
+
+
+ {% endif %}
+ {% if aviso_dv_invalido %}
+ O dígito verificador do documento ({{ dados.CNPJ }}) não confere — confirme se digitou certo antes de salvar.
+ {% endif %}
+
+{% endblock %}
diff --git a/app/templates/clientes_lista.html b/app/templates/clientes_lista.html
new file mode 100644
index 0000000..76ac82b
--- /dev/null
+++ b/app/templates/clientes_lista.html
@@ -0,0 +1,23 @@
+
+{% extends "base.html" %}
+{% block titulo %}Clientes{% endblock %}
+{% block conteudo %}
+ Clientes
+
+ + Novo cliente
+
+ | Código | Razão | Fantasia | CNPJ | |
+ {% for c in clientes %}
+
+ | {{ c.CODIGO }} |
+ {{ c.RAZAO }} |
+ {{ c.FANTASIA }} |
+ {{ c.CNPJ }} |
+ Abrir |
+
+ {% endfor %}
+
+{% endblock %}
diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py
new file mode 100644
index 0000000..16f9983
--- /dev/null
+++ b/tests/test_router_clientes.py
@@ -0,0 +1,81 @@
+"""
+Testa só a camada HTTP: monta um FastAPI() com apenas este router, sobrescreve
+exigir_login (sem exigir Basic Auth real) e faz monkeypatch das funções de
+app.db.clientes usadas pelo router — não toca no banco de verdade.
+"""
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.auth import exigir_login
+from app.routers import clientes as router_clientes
+
+app_teste = FastAPI()
+app_teste.include_router(router_clientes.router)
+app_teste.dependency_overrides[exigir_login] = lambda: "teste"
+client = TestClient(app_teste, follow_redirects=False)
+
+
+def test_listar_clientes(monkeypatch):
+ monkeypatch.setattr(
+ router_clientes.db_clientes,
+ "listar_clientes",
+ lambda busca=None, limite=100: [
+ {"ID": 1, "CODIGO": "000001", "RAZAO": "Cliente Um", "FANTASIA": "Um", "CNPJ": "11222333000181"}
+ ],
+ )
+ resposta = client.get("/clientes")
+ assert resposta.status_code == 200
+ assert "Cliente Um" in resposta.text
+
+
+def test_criar_cliente_codigo_duplicado_exige_confirmacao(monkeypatch):
+ monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: True)
+ criado = {}
+ monkeypatch.setattr(
+ router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) or 42
+ )
+
+ dados_form = {campo: "000001" if campo == "CODIGO" else "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE}
+ dados_form.update(
+ {
+ "IMPLANTACAO": "0",
+ "MENSALIDADE": "100",
+ "JUROS_MORA": "0",
+ "JUROS_MULTA": "0",
+ "DATA_CONTRATO": "2026-01-01",
+ "VENCIMENTO_CONTRATO": "2027-01-01",
+ "DIA_VENCIMENTO": "10",
+ "CNPJ": "11222333000181",
+ }
+ )
+
+ # Sem "confirmar_codigo_duplicado": não deve criar, deve pedir confirmação.
+ resposta = client.post("/clientes/novo", data=dados_form)
+ assert resposta.status_code == 200
+ assert "já está em uso" in resposta.text
+ assert "ok" not in criado
+
+ # Com confirmação: cria.
+ dados_form["confirmar_codigo_duplicado"] = "1"
+ resposta = client.post("/clientes/novo", data=dados_form)
+ assert resposta.status_code == 303
+ assert criado.get("ok") is True
+
+
+def test_excluir_cliente_com_dependencia_mostra_erro(monkeypatch):
+ monkeypatch.setattr(
+ router_clientes.db_clientes,
+ "obter_cliente",
+ lambda cliente_id: {"ID": cliente_id, "CODIGO": "000001", "RAZAO": "R", "FANTASIA": "F"},
+ )
+ monkeypatch.setattr(router_clientes.db_contatos, "listar_contatos_por_cliente", lambda cliente_id: [])
+ monkeypatch.setattr(router_clientes.db_chaves, "status_chaves", lambda codigo: None)
+
+ def _excluir(cliente_id):
+ raise router_clientes.db_clientes.ClienteComDependenciasError("tem contato")
+
+ monkeypatch.setattr(router_clientes.db_clientes, "excluir_cliente", _excluir)
+
+ resposta = client.post("/clientes/1/excluir", follow_redirects=True)
+ assert resposta.status_code == 200
+ assert "tem contato" in resposta.text