feat: bloquear CNPJ com DV inválido, gravar telefone/UF/CEP normalizados
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8b5e7e251e
commit
59f15d9ac0
+48
-18
@@ -20,7 +20,14 @@ from ..db import chaves as db_chaves
|
|||||||
from ..db import clientes as db_clientes
|
from ..db import clientes as db_clientes
|
||||||
from ..db import contatos as db_contatos
|
from ..db import contatos as db_contatos
|
||||||
from ..db import log as db_log
|
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")
|
router = APIRouter(prefix="/clientes")
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
@@ -46,15 +53,17 @@ def _ler_form_cliente(form: dict) -> dict:
|
|||||||
|
|
||||||
def _normalizar_documento_e_telefones(dados: dict) -> None:
|
def _normalizar_documento_e_telefones(dados: dict) -> None:
|
||||||
"""
|
"""
|
||||||
Grava CNPJ/CPF e telefones sempre sem máscara (só dígitos/letras),
|
Grava CNPJ/CPF sempre sem máscara, TELEFONE/CELULAR sempre com DDI 55
|
||||||
igual ao que CLIENTE_CONTATO.TELEFONE já faz — evita que CLIENTE fique
|
embutido, UF em maiúsculas (2 letras) e CEP só com dígitos — evita que
|
||||||
com dois formatos diferentes convivendo. Só afeta escritas novas feitas
|
CLIENTE fique com formatos inconsistentes convivendo. Só afeta
|
||||||
por este painel; registros legados só passam a ficar sem máscara quando
|
escritas novas feitas por este painel; registros legados só passam a
|
||||||
forem reeditados e salvos aqui.
|
ficar normalizados quando forem reeditados e salvos aqui.
|
||||||
"""
|
"""
|
||||||
dados["CNPJ"] = normalizar_documento(dados["CNPJ"])[0]
|
dados["CNPJ"] = normalizar_documento(dados["CNPJ"])[0]
|
||||||
dados["TELEFONE"] = normalizar_telefone(dados["TELEFONE"])
|
dados["TELEFONE"] = normalizar_telefone_com_ddi(dados["TELEFONE"])
|
||||||
dados["CELULAR"] = normalizar_telefone(dados["CELULAR"])
|
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:
|
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)]
|
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)
|
@router.get("", response_class=HTMLResponse)
|
||||||
def listar(
|
def listar(
|
||||||
request: Request,
|
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}
|
request, "cliente_form.html", {**contexto_base, "erro_codigo_duplicado": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
documento_normalizado, formato_valido, tipo = normalizar_documento(dados["CNPJ"])
|
extra_erro = _validar_documento(dados, confirmar_dv)
|
||||||
dv_invalido = False
|
if extra_erro:
|
||||||
if formato_valido:
|
return templates.TemplateResponse(request, "cliente_form.html", {**contexto_base, **extra_erro})
|
||||||
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}
|
|
||||||
)
|
|
||||||
|
|
||||||
_aplicar_defaults_opcionais(dados)
|
_aplicar_defaults_opcionais(dados)
|
||||||
try:
|
try:
|
||||||
@@ -237,6 +262,7 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi
|
|||||||
form = dict((await request.form()))
|
form = dict((await request.form()))
|
||||||
dados = _ler_form_cliente(form)
|
dados = _ler_form_cliente(form)
|
||||||
_normalizar_documento_e_telefones(dados)
|
_normalizar_documento_e_telefones(dados)
|
||||||
|
confirmar_dv = form.get("confirmar_dv_invalido") == "1"
|
||||||
|
|
||||||
contexto_base = {
|
contexto_base = {
|
||||||
"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
|
"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}
|
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)
|
_aplicar_defaults_opcionais(dados)
|
||||||
try:
|
try:
|
||||||
db_clientes.atualizar_cliente(cliente_id, dados)
|
db_clientes.atualizar_cliente(cliente_id, dados)
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if erro_cnpj_invalido %}
|
||||||
|
<div class="mensagem danger">
|
||||||
|
O dígito verificador do CNPJ ({{ dados.CNPJ }}) não confere — corrija antes de salvar.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if erro_gravacao %}
|
{% if erro_gravacao %}
|
||||||
<div class="mensagem danger">
|
<div class="mensagem danger">
|
||||||
Não foi possível salvar: {{ erro_gravacao }}
|
Não foi possível salvar: {{ erro_gravacao }}
|
||||||
|
|||||||
@@ -295,8 +295,8 @@ def test_criar_cliente_normaliza_cnpj_e_telefones(monkeypatch):
|
|||||||
resposta = client.post("/clientes/novo", data=dados_form)
|
resposta = client.post("/clientes/novo", data=dados_form)
|
||||||
assert resposta.status_code == 303
|
assert resposta.status_code == 303
|
||||||
assert capturado["dados"]["CNPJ"] == "11222333000181"
|
assert capturado["dados"]["CNPJ"] == "11222333000181"
|
||||||
assert capturado["dados"]["TELEFONE"] == "7533334444"
|
assert capturado["dados"]["TELEFONE"] == "557533334444"
|
||||||
assert capturado["dados"]["CELULAR"] == "75999091780"
|
assert capturado["dados"]["CELULAR"] == "5575999091780"
|
||||||
|
|
||||||
|
|
||||||
def test_editar_cliente_normaliza_cnpj_e_telefones(monkeypatch):
|
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)
|
resposta = client.post("/clientes/1/editar", data=dados_form)
|
||||||
assert resposta.status_code == 303
|
assert resposta.status_code == 303
|
||||||
assert capturado["dados"]["CNPJ"] == "11144477735"
|
assert capturado["dados"]["CNPJ"] == "11144477735"
|
||||||
assert capturado["dados"]["TELEFONE"] == "7533334444"
|
assert capturado["dados"]["TELEFONE"] == "557533334444"
|
||||||
assert capturado["dados"]["CELULAR"] == "75999091780"
|
assert capturado["dados"]["CELULAR"] == "5575999091780"
|
||||||
|
|
||||||
|
|
||||||
def test_editar_cliente_codigo_duplicado_bloqueia_sem_bypass(monkeypatch):
|
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)
|
resposta = client.post("/clientes/novo", data=dados_form)
|
||||||
assert resposta.status_code == 303
|
assert resposta.status_code == 303
|
||||||
assert criado.get("ok") is True
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user