From 669e864141f95d895dc99b21f1eca63e751acb66 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 09:42:10 -0300 Subject: [PATCH] feat: normalizacao de documento + validacao de DV (CPF/CNPJ alfanumerico) --- app/normalize.py | 84 +++++++++++++++++++++++++++++++++++++++++ tests/test_normalize.py | 80 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 app/normalize.py create mode 100644 tests/test_normalize.py diff --git a/app/normalize.py b/app/normalize.py new file mode 100644 index 0000000..19943df --- /dev/null +++ b/app/normalize.py @@ -0,0 +1,84 @@ +""" +Funções de normalização e validação de documento (CPF/CNPJ) e telefone. + +Formato de CNPJ alfanumérico vigente desde 31/07/2026 (Instrução Normativa +RFB nº 2.229/2024, Nota Técnica COCAD/SUARA/RFB nº 49/2024): 14 posições, +sendo as 12 primeiras alfanuméricas (0-9, A-Z — raiz + filial) e as 2 +últimas numéricas (dígitos verificadores, módulo 11 sobre valor ASCII − 48 +de cada caractere). CNPJs numéricos antigos continuam válidos e geram o +mesmo DV pela fórmula nova (retrocompatível). + +`normalizar_documento` valida só o FORMATO (tamanho/charset) — é o mesmo +contrato já usado no workflow n8n (node "05 - Normalizar CNPJ") e não deve +divergir dele. `validar_dv_cpf`/`validar_dv_cnpj` são checagens ADICIONAIS +de dígito verificador, usadas pela UI como aviso (não bloqueio) — o legado +aceita documentos com DV matematicamente inválido. +""" +import re + +_RE_NAO_ALFANUM = re.compile(r"[^0-9A-Z]") +_RE_CPF = re.compile(r"^[0-9]{11}$") +_RE_CNPJ = re.compile(r"^[0-9A-Z]{12}[0-9]{2}$") +_RE_NAO_DIGITO = re.compile(r"[^0-9]") + + +def normalizar_documento(bruto: str | None) -> tuple[str, bool, str | None]: + """ + Normaliza CPF/CNPJ: maiúsculas, só dígitos e letras A-Z (remove pontos, + barras, hífens, espaços). + + CPF: 11 dígitos numéricos. + CNPJ: 12 caracteres alfanuméricos (0-9, A-Z) + 2 dígitos verificadores + numéricos. + + Retorna (documento_normalizado, formato_valido, tipo) onde tipo é "CPF", + "CNPJ" ou None se inválido. + """ + limpo = _RE_NAO_ALFANUM.sub("", (bruto or "").upper()) + eh_cpf = bool(_RE_CPF.match(limpo)) + eh_cnpj = bool(_RE_CNPJ.match(limpo)) + tipo = "CPF" if eh_cpf else ("CNPJ" if eh_cnpj else None) + return limpo, (eh_cpf or eh_cnpj), tipo + + +def normalizar_telefone(bruto: str | None) -> str: + """Remove tudo que não for dígito (espaços, parênteses, traços, "+").""" + return _RE_NAO_DIGITO.sub("", bruto or "") + + +_PESOS_CPF_DV1 = [10, 9, 8, 7, 6, 5, 4, 3, 2] +_PESOS_CPF_DV2 = [11, 10, 9, 8, 7, 6, 5, 4, 3, 2] +_PESOS_CNPJ_DV1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] +_PESOS_CNPJ_DV2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] + + +def _digito_verificador(valores: list[int], pesos: list[int]) -> int: + soma = sum(v * p for v, p in zip(valores, pesos)) + resto = soma % 11 + return 0 if resto < 2 else 11 - resto + + +def validar_dv_cpf(cpf_normalizado: str) -> bool: + """Confere os 2 dígitos verificadores de um CPF já normalizado (11 dígitos).""" + digitos = [int(c) for c in cpf_normalizado] + dv1 = _digito_verificador(digitos[:9], _PESOS_CPF_DV1) + dv2 = _digito_verificador(digitos[:9] + [dv1], _PESOS_CPF_DV2) + return digitos[9] == dv1 and digitos[10] == dv2 + + +def _valor_caractere(c: str) -> int: + """Valor ASCII − 48: dígitos 0-9 valem 0-9, letras A-Z valem 17-42.""" + return ord(c) - 48 + + +def validar_dv_cnpj(cnpj_normalizado: str) -> bool: + """ + Confere os 2 dígitos verificadores de um CNPJ já normalizado (12 + alfanuméricos + 2 numéricos). Funciona tanto para CNPJ numérico antigo + quanto para o novo formato alfanumérico (mesma fórmula, retrocompatível). + """ + base = [_valor_caractere(c) for c in cnpj_normalizado[:12]] + dv_informado = [int(c) for c in cnpj_normalizado[12:14]] + dv1 = _digito_verificador(base, _PESOS_CNPJ_DV1) + dv2 = _digito_verificador(base + [dv1], _PESOS_CNPJ_DV2) + return dv_informado[0] == dv1 and dv_informado[1] == dv2 diff --git a/tests/test_normalize.py b/tests/test_normalize.py new file mode 100644 index 0000000..86e8f46 --- /dev/null +++ b/tests/test_normalize.py @@ -0,0 +1,80 @@ +""" +Fixtures de CPF/CNPJ verificadas manualmente: +- CPF "11144477735": DV clássico módulo 11, exemplo público amplamente usado + em validadores de CPF. +- CNPJ numérico "11222333000181": exemplo público amplamente usado em + validadores de CNPJ. +- CNPJ alfanumérico "00000000E08G12": primeiro CNPJ alfanumérico realmente + emitido no Brasil (Banco do Bradil, 02/08/2026) — confirma que o algoritmo + bate com um caso real, não só um exemplo didático. +""" +from app.normalize import ( + normalizar_documento, + normalizar_telefone, + validar_dv_cpf, + validar_dv_cnpj, +) + + +def test_normaliza_cpf_com_mascara(): + doc, valido, tipo = normalizar_documento("111.444.777-35") + assert doc == "11144477735" + assert valido is True + assert tipo == "CPF" + + +def test_normaliza_cnpj_numerico_com_mascara(): + doc, valido, tipo = normalizar_documento("11.222.333/0001-81") + assert doc == "11222333000181" + assert valido is True + assert tipo == "CNPJ" + + +def test_normaliza_cnpj_alfanumerico_com_mascara(): + doc, valido, tipo = normalizar_documento("00.000.000/E08G-12") + assert doc == "00000000E08G12" + assert valido is True + assert tipo == "CNPJ" + + +def test_documento_invalido_tamanho_errado(): + doc, valido, tipo = normalizar_documento("123") + assert valido is False + assert tipo is None + + +def test_documento_none(): + doc, valido, tipo = normalizar_documento(None) + assert doc == "" + assert valido is False + assert tipo is None + + +def test_normaliza_telefone(): + assert normalizar_telefone("(75) 99909-1780") == "75999091780" + assert normalizar_telefone(None) == "" + + +def test_validar_dv_cpf_correto(): + assert validar_dv_cpf("11144477735") is True + + +def test_validar_dv_cpf_incorreto(): + assert validar_dv_cpf("11144477734") is False + + +def test_validar_dv_cnpj_numerico_correto(): + assert validar_dv_cnpj("11222333000181") is True + + +def test_validar_dv_cnpj_numerico_incorreto(): + assert validar_dv_cnpj("11222333000180") is False + + +def test_validar_dv_cnpj_alfanumerico_correto(): + # Primeiro CNPJ alfanumérico real emitido no Brasil. + assert validar_dv_cnpj("00000000E08G12") is True + + +def test_validar_dv_cnpj_alfanumerico_incorreto(): + assert validar_dv_cnpj("00000000E08G13") is False