Copies the 6 pure fiscal modules (domains, chave_acesso, tpag, presets, resolver, xml_builder) from sowai-auto's backend/app/modules/fiscal/ into this standalone lib, with only the two mechanical changes required to drop the app.* dependency: import prefix rename, and TaxRule (ORM) -> TaxRuleLike (structural Protocol) in the resolver. No other line changes -- byte-level parity with the auto is the point. Ports the pure test suites (domains, chave_acesso, resolver with a local FakeTaxRule satisfying TaxRuleLike, xml_builder) plus a new test_presets_data covering the PRESETS dict directly (the auto's equivalent test exercises the apply-preset HTTP/DB flow, which isn't part of the extracted pure core). 55 tests pass locally via `uv run pytest`, no Postgres/k8s required.
114 lines
4.7 KiB
Python
114 lines
4.7 KiB
Python
"""Chave de acesso NF-e: cUF(2)+AAMM(4)+CNPJ(14)+mod(2)+série(3)+nNF(9)+
|
|
tpEmis(1)+cNF(8)+DV(1) = 44 dígitos. DV = módulo 11 (pesos 2..9, da direita
|
|
para a esquerda; resto 0 ou 1 -> DV 0).
|
|
|
|
`dv_modulo11` é validado contra chaves REAIS (não inventadas) -- mas NÃO as
|
|
de `tests/modules/estoque/test_nfe_import.py` como o plano original
|
|
cogitava: aquele fixture é hand-written (docstring do próprio arquivo o diz)
|
|
e tem só UMA chave de 44 dígitos, cujo último dígito NÃO bate com o DV
|
|
módulo 11 (conferido por script antes de escrever este teste -- não é uma
|
|
chave real, é um ID de exemplo forjado só para exercitar o parser). Fonte
|
|
usada em vez disso: os XMLs de amostra que o próprio pacote `nfelib`
|
|
(dependência já pinada em pyproject.toml, versão instalada no pod
|
|
confirmada em 2.5.2 na Task 1 Step 0) empacota em
|
|
`nfelib/nfe/samples/v4_0/leiauteNFe/*.xml` -- os NOMES desses arquivos SÃO
|
|
as chaves de acesso de NF-e reais/realistas de exemplo, com DV
|
|
correto -- confirmado por decomposição manual antes de escrever este teste:
|
|
`35180834128745000152550010000476711079516696` (mod=55, DV correto=6) e
|
|
`35200159594315000157550010000000012062777161` (mod=55, DV correto=1,
|
|
também o `Id` do `infNFe` dentro de
|
|
`NFe35200159594315000157550010000000012062777161.xml`)."""
|
|
import pytest
|
|
|
|
from sowai_fiscal.chave_acesso import dv_modulo11, gerar_cnf, montar_chave_acesso
|
|
|
|
|
|
def test_dv_modulo11_contra_chave_real_1_amostra_nfelib():
|
|
chave44 = "35180834128745000152550010000476711079516696"
|
|
assert dv_modulo11(chave44[:43]) == chave44[43]
|
|
|
|
|
|
def test_dv_modulo11_contra_chave_real_2_amostra_nfelib():
|
|
chave44 = "35200159594315000157550010000000012062777161"
|
|
assert dv_modulo11(chave44[:43]) == chave44[43]
|
|
|
|
|
|
def test_dv_modulo11_rejeita_chave_com_tamanho_errado():
|
|
with pytest.raises(ValueError):
|
|
dv_modulo11("123")
|
|
|
|
|
|
def test_montar_chave_tem_44_digitos_e_dv_valido():
|
|
chave = montar_chave_acesso(
|
|
uf_ibge="41", aamm="2607", cnpj="12345678000190",
|
|
modelo="55", serie=1, numero=1014, tp_emis="1", cnf="87654321",
|
|
)
|
|
assert len(chave) == 44 and chave.isdigit()
|
|
assert chave[-1] == dv_modulo11(chave[:43])
|
|
assert chave[22:25] == "001" and chave[25:34] == "000001014"
|
|
|
|
|
|
def test_gerar_cnf_8_digitos_e_diferente_do_nnf():
|
|
for _ in range(50):
|
|
cnf = gerar_cnf(numero_nnf=1014)
|
|
assert len(cnf) == 8 and cnf.isdigit()
|
|
assert int(cnf) != 1014 # regra NT2019.001: cNF != nNF
|
|
|
|
|
|
def test_gerar_cnf_nunca_all_zeros():
|
|
# F3a (review 2026-07-16): all-zeros é 8 dígitos válidos no FORMATO mas
|
|
# é o chute óbvio de um gerador fraco -- nunca deve sair do sorteio,
|
|
# mesmo por acaso estatístico (1 em 10^8 por chamada; 200 chamadas aqui
|
|
# deixa a chance de falso-negativo desprezível).
|
|
for _ in range(200):
|
|
assert gerar_cnf(numero_nnf=1014) != "00000000"
|
|
|
|
|
|
def test_montar_chave_acesso_rejeita_componentes_que_se_compensam_no_total():
|
|
# F1 (review 2026-07-16, Important): o bug real -- checar SÓ o tamanho
|
|
# agregado (43 dígitos) deixava passar um CNPJ de 13 dígitos (errado,
|
|
# deveria ter 14) somado a um cNF de 9 dígitos (errado, deveria ter 8):
|
|
# 13 + 9 = 22, o mesmo total de dígitos que o par correto 14 + 8 = 22 --
|
|
# a chave "compensada" saía com 43 dígitos e um DV módulo-11 sintaticamente
|
|
# válido, mas apontando para o CNPJ ERRADO (um dígito do cNF vazando para
|
|
# dentro do que deveria ser o CNPJ). A validação por COMPONENTE (não só o
|
|
# total) tem que capturar isto.
|
|
with pytest.raises(ValueError, match="cnpj"):
|
|
montar_chave_acesso(
|
|
uf_ibge="41", aamm="2607", cnpj="1234567800019", # 13 dígitos, não 14
|
|
modelo="55", serie=1, numero=1014, tp_emis="1",
|
|
cnf="876543210", # 9 dígitos, não 8 -- compensa o total em 43
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"campo,valor",
|
|
[
|
|
("uf_ibge", "4"),
|
|
("uf_ibge", "411"),
|
|
("aamm", "260"),
|
|
("cnpj", "1234567800019"),
|
|
("cnpj", "123456780001900"),
|
|
("modelo", "5"),
|
|
("tp_emis", "12"),
|
|
("cnf", "8765432"),
|
|
("cnf", "876543210"),
|
|
],
|
|
)
|
|
def test_montar_chave_acesso_rejeita_cada_componente_fora_do_tamanho(campo, valor):
|
|
kwargs = dict(
|
|
uf_ibge="41", aamm="2607", cnpj="12345678000190",
|
|
modelo="55", serie=1, numero=1014, tp_emis="1", cnf="87654321",
|
|
)
|
|
kwargs[campo] = valor
|
|
with pytest.raises(ValueError):
|
|
montar_chave_acesso(**kwargs)
|
|
|
|
|
|
def test_montar_chave_acesso_rejeita_componente_nao_numerico():
|
|
with pytest.raises(ValueError, match="cnpj"):
|
|
montar_chave_acesso(
|
|
uf_ibge="41", aamm="2607", cnpj="1234567800019A",
|
|
modelo="55", serie=1, numero=1014, tp_emis="1", cnf="87654321",
|
|
)
|