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"