diff --git a/app/routers/clientes.py b/app/routers/clientes.py
index fe37e01..f29a1e9 100644
--- a/app/routers/clientes.py
+++ b/app/routers/clientes.py
@@ -20,7 +20,14 @@ from ..db import chaves as db_chaves
from ..db import clientes as db_clientes
from ..db import contatos as db_contatos
from ..db import log as db_log
-from ..normalize import normalizar_documento, normalizar_telefone, validar_dv_cpf, validar_dv_cnpj
+from ..normalize import (
+ normalizar_documento,
+ normalizar_telefone_com_ddi,
+ normalizar_uf,
+ normalizar_cep,
+ validar_dv_cpf,
+ validar_dv_cnpj,
+)
router = APIRouter(prefix="/clientes")
templates = Jinja2Templates(directory="app/templates")
@@ -46,15 +53,17 @@ def _ler_form_cliente(form: dict) -> dict:
def _normalizar_documento_e_telefones(dados: dict) -> None:
"""
- Grava CNPJ/CPF e telefones sempre sem máscara (só dígitos/letras),
- igual ao que CLIENTE_CONTATO.TELEFONE já faz — evita que CLIENTE fique
- com dois formatos diferentes convivendo. Só afeta escritas novas feitas
- por este painel; registros legados só passam a ficar sem máscara quando
- forem reeditados e salvos aqui.
+ Grava CNPJ/CPF sempre sem máscara, TELEFONE/CELULAR sempre com DDI 55
+ embutido, UF em maiúsculas (2 letras) e CEP só com dígitos — evita que
+ CLIENTE fique com formatos inconsistentes convivendo. Só afeta
+ escritas novas feitas por este painel; registros legados só passam a
+ ficar normalizados quando forem reeditados e salvos aqui.
"""
dados["CNPJ"] = normalizar_documento(dados["CNPJ"])[0]
- dados["TELEFONE"] = normalizar_telefone(dados["TELEFONE"])
- dados["CELULAR"] = normalizar_telefone(dados["CELULAR"])
+ dados["TELEFONE"] = normalizar_telefone_com_ddi(dados["TELEFONE"])
+ dados["CELULAR"] = normalizar_telefone_com_ddi(dados["CELULAR"])
+ dados["UF"] = normalizar_uf(dados["UF"])
+ dados["CEP"] = normalizar_cep(dados["CEP"])
def _aplicar_defaults_opcionais(dados: dict) -> None:
@@ -77,6 +86,29 @@ def _campos_obrigatorios_faltando(dados: dict) -> list[str]:
return [campo for campo in CAMPOS_OBRIGATORIOS if not dados.get(campo)]
+def _validar_documento(dados: dict, confirmar_dv: bool) -> dict:
+ """
+ Confere o dígito verificador do documento em CLIENTE.CNPJ (o mesmo
+ campo aceita CPF ou CNPJ, ver app/normalize.py). CNPJ com DV inválido
+ bloqueia sempre — não existe "cadastrar mesmo assim" para CNPJ. CPF
+ com DV inválido mantém o fluxo já existente: aviso + confirmação.
+
+ Retorna um dict para mesclar no contexto do template: {} se o
+ documento está ok (ou é indeterminado), ou uma das chaves de erro que
+ cliente_form.html já sabe renderizar.
+ """
+ documento_normalizado, formato_valido, tipo = normalizar_documento(dados["CNPJ"])
+ if not formato_valido:
+ return {}
+ if tipo == "CNPJ":
+ if not validar_dv_cnpj(documento_normalizado):
+ return {"erro_cnpj_invalido": True}
+ return {}
+ if not validar_dv_cpf(documento_normalizado) and not confirmar_dv:
+ return {"aviso_dv_invalido": True}
+ return {}
+
+
@router.get("", response_class=HTMLResponse)
def listar(
request: Request,
@@ -151,16 +183,9 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)):
request, "cliente_form.html", {**contexto_base, "erro_codigo_duplicado": True}
)
- documento_normalizado, formato_valido, tipo = normalizar_documento(dados["CNPJ"])
- dv_invalido = False
- if formato_valido:
- dv_invalido = not (
- validar_dv_cpf(documento_normalizado) if tipo == "CPF" else validar_dv_cnpj(documento_normalizado)
- )
- if dv_invalido and not confirmar_dv:
- return templates.TemplateResponse(
- request, "cliente_form.html", {**contexto_base, "aviso_dv_invalido": True}
- )
+ extra_erro = _validar_documento(dados, confirmar_dv)
+ if extra_erro:
+ return templates.TemplateResponse(request, "cliente_form.html", {**contexto_base, **extra_erro})
_aplicar_defaults_opcionais(dados)
try:
@@ -237,6 +262,7 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi
form = dict((await request.form()))
dados = _ler_form_cliente(form)
_normalizar_documento_e_telefones(dados)
+ confirmar_dv = form.get("confirmar_dv_invalido") == "1"
contexto_base = {
"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
@@ -255,6 +281,10 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi
request, "cliente_form.html", {**contexto_base, "erro_codigo_duplicado": True}
)
+ extra_erro = _validar_documento(dados, confirmar_dv)
+ if extra_erro:
+ return templates.TemplateResponse(request, "cliente_form.html", {**contexto_base, **extra_erro})
+
_aplicar_defaults_opcionais(dados)
try:
db_clientes.atualizar_cliente(cliente_id, dados)
diff --git a/app/templates/cliente_form.html b/app/templates/cliente_form.html
index a4166bf..6985489 100644
--- a/app/templates/cliente_form.html
+++ b/app/templates/cliente_form.html
@@ -19,6 +19,11 @@
{% endif %}
+ {% if erro_cnpj_invalido %}
+
+ O dígito verificador do CNPJ ({{ dados.CNPJ }}) não confere — corrija antes de salvar.
+
+ {% endif %}
{% if erro_gravacao %}
Não foi possível salvar: {{ erro_gravacao }}
diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py
index 40dad1e..c84065a 100644
--- a/tests/test_router_clientes.py
+++ b/tests/test_router_clientes.py
@@ -295,8 +295,8 @@ def test_criar_cliente_normaliza_cnpj_e_telefones(monkeypatch):
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 303
assert capturado["dados"]["CNPJ"] == "11222333000181"
- assert capturado["dados"]["TELEFONE"] == "7533334444"
- assert capturado["dados"]["CELULAR"] == "75999091780"
+ assert capturado["dados"]["TELEFONE"] == "557533334444"
+ assert capturado["dados"]["CELULAR"] == "5575999091780"
def test_editar_cliente_normaliza_cnpj_e_telefones(monkeypatch):
@@ -320,8 +320,8 @@ def test_editar_cliente_normaliza_cnpj_e_telefones(monkeypatch):
resposta = client.post("/clientes/1/editar", data=dados_form)
assert resposta.status_code == 303
assert capturado["dados"]["CNPJ"] == "11144477735"
- assert capturado["dados"]["TELEFONE"] == "7533334444"
- assert capturado["dados"]["CELULAR"] == "75999091780"
+ assert capturado["dados"]["TELEFONE"] == "557533334444"
+ assert capturado["dados"]["CELULAR"] == "5575999091780"
def test_editar_cliente_codigo_duplicado_bloqueia_sem_bypass(monkeypatch):
@@ -407,3 +407,116 @@ def test_criar_cliente_com_dv_invalido_exige_confirmacao(monkeypatch):
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 303
assert criado.get("ok") is True
+
+
+def test_criar_cliente_com_cnpj_dv_invalido_bloqueia_sempre(monkeypatch):
+ """
+ Diferente de CPF: CNPJ com DV inválido bloqueia sempre, sem opção de
+ "cadastrar mesmo assim" — nem com confirmar_dv_invalido=1.
+ """
+ monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
+ criado = {}
+ monkeypatch.setattr(
+ router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) and 42
+ )
+
+ dados_form = _form_valido()
+ dados_form["CNPJ"] = "11222333000180" # último dígito trocado: DV inválido
+
+ resposta = client.post("/clientes/novo", data=dados_form)
+ assert resposta.status_code == 200
+ assert "não confere" in resposta.text
+ assert "ok" not in criado
+
+ # Mesmo confirmando, continua bloqueando (diferente do fluxo de CPF).
+ dados_form["confirmar_dv_invalido"] = "1"
+ resposta = client.post("/clientes/novo", data=dados_form)
+ assert resposta.status_code == 200
+ assert "ok" not in criado
+
+
+def test_editar_cliente_com_cnpj_dv_invalido_bloqueia(monkeypatch):
+ monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
+ atualizado = {}
+ monkeypatch.setattr(
+ router_clientes.db_clientes,
+ "atualizar_cliente",
+ lambda cliente_id, dados: atualizado.setdefault("ok", True),
+ )
+
+ dados_form = _form_valido()
+ dados_form["CNPJ"] = "11222333000180"
+
+ resposta = client.post("/clientes/1/editar", data=dados_form)
+ assert resposta.status_code == 200
+ assert "não confere" in resposta.text
+ assert "ok" not in atualizado
+
+
+def test_criar_cliente_com_cpf_dv_invalido_continua_permitindo_confirmar(monkeypatch):
+ """Regressão: CPF com DV inválido não muda de comportamento."""
+ monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
+ criado = {}
+ monkeypatch.setattr(
+ router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) and 42
+ )
+
+ dados_form = _form_valido()
+ dados_form["CNPJ"] = "11144477734" # CPF com DV inválido
+
+ resposta = client.post("/clientes/novo", data=dados_form)
+ assert resposta.status_code == 200
+ assert "não confere" in resposta.text
+ assert "ok" not in criado
+
+ dados_form["confirmar_dv_invalido"] = "1"
+ resposta = client.post("/clientes/novo", data=dados_form)
+ assert resposta.status_code == 303
+ assert criado.get("ok") is True
+
+
+def test_criar_cliente_grava_telefone_com_ddi_55(monkeypatch):
+ monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
+ capturado = {}
+ monkeypatch.setattr(
+ router_clientes.db_clientes, "criar_cliente", lambda dados: capturado.setdefault("dados", dados) and 42
+ )
+
+ dados_form = _form_valido()
+ dados_form.update({"TELEFONE": "(75) 3333-4444", "CELULAR": "(75) 99909-1780"})
+
+ resposta = client.post("/clientes/novo", data=dados_form)
+ assert resposta.status_code == 303
+ assert capturado["dados"]["TELEFONE"] == "557533334444"
+ assert capturado["dados"]["CELULAR"] == "5575999091780"
+
+
+def test_criar_cliente_telefone_ja_com_55_nao_duplica(monkeypatch):
+ monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
+ capturado = {}
+ monkeypatch.setattr(
+ router_clientes.db_clientes, "criar_cliente", lambda dados: capturado.setdefault("dados", dados) and 42
+ )
+
+ dados_form = _form_valido()
+ dados_form["CELULAR"] = "5575999091780"
+
+ resposta = client.post("/clientes/novo", data=dados_form)
+ assert resposta.status_code == 303
+ assert capturado["dados"]["CELULAR"] == "5575999091780"
+
+
+def test_criar_cliente_grava_uf_maiuscula_e_cep_sem_traco(monkeypatch):
+ monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
+ capturado = {}
+ monkeypatch.setattr(
+ router_clientes.db_clientes, "criar_cliente", lambda dados: capturado.setdefault("dados", dados) and 42
+ )
+
+ dados_form = _form_valido()
+ dados_form.update({"UF": "ba", "CEP": "44430-174"})
+
+ resposta = client.post("/clientes/novo", data=dados_form)
+ assert resposta.status_code == 303
+ assert capturado["dados"]["UF"] == "BA"
+ assert capturado["dados"]["CEP"] == "44430174"