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

+ + + {% for ct in contatos %} + + {% endfor %} +
NomeTelefoneE-mail
{{ ct.NOME }}{{ ct.TELEFONE }}{{ ct.EMAIL or "" }}
+ +

Status em Clientes_Chaves

+ {% if status_chaves %} + + {% for mes in status_chaves.meses %}{% endfor %} + {% for mes, valor in status_chaves.meses.items() %}{% endfor %} +
{{ mes }}
{{ "✓" if valor else "—" }}
+ {% 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. +
+ {% for campo, valor in dados.items() %}{% endfor %} + + +
+
+ {% endif %} + {% if aviso_dv_invalido %} +
O dígito verificador do documento ({{ dados.CNPJ }}) não confere — confirme se digitou certo antes de salvar.
+ {% endif %} +
+ {% for campo in campos %} + + + {% endfor %} + +
+{% 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

+ + + {% for c in clientes %} + + + + + + + + {% endfor %} +
CódigoRazãoFantasiaCNPJ
{{ c.CODIGO }}{{ c.RAZAO }}{{ c.FANTASIA }}{{ c.CNPJ }}Abrir
+{% 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