feat: extract pure fiscal core from sowai-auto (parity-preserving copy)
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.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
"""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",
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Bloco B (spec 2026-07-14): catálogo de domínios tributários.
|
||||
|
||||
`tax_domain` é String no banco de propósito (imposto-como-dado): um domínio
|
||||
novo (IBS/CBS/futuro) é um membro novo NO ENUM PYTHON, sem ALTER TYPE, sem
|
||||
migration, sem o gotcha enum-NAME-vs-value. Estes testes travam o contrato
|
||||
que o frontend consome via GET /fiscal/tax-domains e a tabela de pesos/
|
||||
transposição que o resolvedor usa.
|
||||
"""
|
||||
from sowai_fiscal.domains import (
|
||||
DOMAIN_FIELDS,
|
||||
MATCHER_WEIGHTS,
|
||||
TaxDomain,
|
||||
transpose_cfop,
|
||||
)
|
||||
|
||||
|
||||
def test_tax_domain_has_the_reform_domains_without_any_migration():
|
||||
# A prova literal do "imposto-como-dado": IBS/CBS já são domínios aceitos
|
||||
# hoje, e não existe NENHUM tipo enum Postgres para tax_domain.
|
||||
assert TaxDomain.IBS.value == "ibs"
|
||||
assert TaxDomain.CBS.value == "cbs"
|
||||
assert {d.value for d in TaxDomain} >= {
|
||||
"icms", "icmsst", "ipi", "pis", "cofins", "difal", "fcp", "ibs", "cbs", "iss",
|
||||
}
|
||||
|
||||
|
||||
def test_domain_fields_catalog_covers_every_domain():
|
||||
# O frontend monta o form por domínio a partir deste catálogo — cada
|
||||
# domínio precisa declarar campos aplicáveis e obrigatórios.
|
||||
for domain in TaxDomain:
|
||||
spec = DOMAIN_FIELDS[domain.value]
|
||||
assert spec.label
|
||||
assert set(spec.campos_obrigatorios) <= set(spec.campos_aplicaveis)
|
||||
for grupo in spec.campos_um_de:
|
||||
assert set(grupo) <= set(spec.campos_aplicaveis)
|
||||
assert not set(grupo) & set(spec.campos_obrigatorios) # um-de nunca também obrigatório
|
||||
|
||||
|
||||
def test_icms_declares_the_exclusive_situacao_group():
|
||||
# A UI exige "situação tributária" (cst OU csosn) a partir DAQUI — sem
|
||||
# este grupo no catálogo, o form dinâmico deixaria passar regra de ICMS
|
||||
# sem situação e o usuário comeria um 422 imprevisto (review frontend).
|
||||
assert ("cst", "csosn") in DOMAIN_FIELDS["icms"].campos_um_de
|
||||
assert ("cst", "csosn") in DOMAIN_FIELDS["icmsst"].campos_um_de
|
||||
|
||||
|
||||
def test_matcher_weights_are_strict_powers_of_two():
|
||||
# Cada peso supera a SOMA de todos os mais fracos (64 > 63): nenhuma
|
||||
# combinação de matchers genéricos vence um mais seletivo. É a correção
|
||||
# do review do frontend sobre "contagem de matchers".
|
||||
ordered = sorted(MATCHER_WEIGHTS.values())
|
||||
for i, w in enumerate(ordered):
|
||||
assert w > sum(ordered[:i])
|
||||
assert MATCHER_WEIGHTS["ncm_prefix"] == 64
|
||||
assert MATCHER_WEIGHTS["cest"] == 32
|
||||
assert MATCHER_WEIGHTS["consumidor_final"] == 16
|
||||
assert MATCHER_WEIGHTS["indicador_ie"] == 8
|
||||
assert MATCHER_WEIGHTS["uf_destino_tipo"] == 4
|
||||
assert MATCHER_WEIGHTS["tipo_operacao"] == 2
|
||||
assert MATCHER_WEIGHTS["crt"] == 1
|
||||
|
||||
|
||||
def test_transpose_cfop_uses_the_explicit_table_for_st():
|
||||
# 5405 → 6403, NÃO 6405: é por isso que a tabela explícita existe.
|
||||
assert transpose_cfop("5405") == "6403"
|
||||
assert transpose_cfop("5102") == "6102"
|
||||
assert transpose_cfop("1202") == "2202"
|
||||
|
||||
|
||||
def test_transpose_cfop_generic_fallback_swaps_the_first_digit():
|
||||
# CFOP fora da tabela: regra geral 5xxx→6xxx / 1xxx→2xxx.
|
||||
assert transpose_cfop("5949") == "6949"
|
||||
assert transpose_cfop("1949") == "2949"
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Presets fiscais seed-editáveis: teste PURO do dict `PRESETS` (dados,
|
||||
não a API `/tax-profiles/apply-preset/*` -- aquela é do produto, DB/HTTP,
|
||||
não faz parte do núcleo puro extraído). Os valores REAIS vieram do iCode
|
||||
da Thiago (perfis "Padrão" cód 110 e "Óleos/ST" cód 108) -- estes testes
|
||||
travam o contrato de dados que o consumidor (auto) usa para semear
|
||||
`TaxRule`/`TaxProfile`."""
|
||||
from decimal import Decimal
|
||||
|
||||
from sowai_fiscal.presets import PRESETS, FiscalPreset, PresetRule
|
||||
|
||||
|
||||
def test_presets_catalog_has_the_two_icode_profiles():
|
||||
assert {"autopecas_simples_padrao", "autopecas_simples_st"} <= PRESETS.keys()
|
||||
|
||||
|
||||
def test_padrao_preset_has_venda_and_devolucao_icms_rules():
|
||||
preset = PRESETS["autopecas_simples_padrao"]
|
||||
assert isinstance(preset, FiscalPreset)
|
||||
assert preset.regime_alvo == "1"
|
||||
|
||||
icms_rules = [r for r in preset.rules if r.tax_domain == "icms"]
|
||||
assert len(icms_rules) == 2
|
||||
|
||||
venda = next(r for r in icms_rules if r.tipo_operacao == "venda")
|
||||
assert venda.csosn == "102"
|
||||
assert venda.cfop == "5102"
|
||||
assert venda.aliquota == Decimal("0")
|
||||
|
||||
devolucao = next(r for r in icms_rules if r.tipo_operacao == "devolucao")
|
||||
assert devolucao.csosn == "102"
|
||||
assert devolucao.cfop == "1202"
|
||||
|
||||
|
||||
def test_padrao_preset_pis_cofins_cst_08():
|
||||
preset = PRESETS["autopecas_simples_padrao"]
|
||||
pis = next(r for r in preset.rules if r.tax_domain == "pis")
|
||||
cofins = next(r for r in preset.rules if r.tax_domain == "cofins")
|
||||
assert pis.cst == "08"
|
||||
assert cofins.cst == "08"
|
||||
assert pis.aliquota == Decimal("0")
|
||||
assert cofins.aliquota == Decimal("0")
|
||||
|
||||
|
||||
def test_st_preset_has_venda_and_devolucao_icmsst_rules():
|
||||
preset = PRESETS["autopecas_simples_st"]
|
||||
icmsst_rules = [r for r in preset.rules if r.tax_domain == "icmsst"]
|
||||
assert len(icmsst_rules) == 2
|
||||
|
||||
venda = next(r for r in icmsst_rules if r.tipo_operacao == "venda")
|
||||
assert venda.csosn == "500"
|
||||
assert venda.cfop == "5405"
|
||||
|
||||
devolucao = next(r for r in icmsst_rules if r.tipo_operacao == "devolucao")
|
||||
assert devolucao.csosn == "500"
|
||||
assert devolucao.cfop == "1202"
|
||||
|
||||
|
||||
def test_st_preset_pis_cofins_cst_04_monofasico():
|
||||
preset = PRESETS["autopecas_simples_st"]
|
||||
pis = next(r for r in preset.rules if r.tax_domain == "pis")
|
||||
cofins = next(r for r in preset.rules if r.tax_domain == "cofins")
|
||||
assert pis.cst == "04"
|
||||
assert cofins.cst == "04"
|
||||
|
||||
|
||||
def test_no_preset_rule_carries_origem():
|
||||
# NENHUM preset carrega `origem`: origem é sempre do Part (review do
|
||||
# frontend, 2026-07-14) -- `PresetRule` não tem sequer o campo.
|
||||
assert not hasattr(PresetRule(tax_domain="icms"), "origem")
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Resolvedor puro do Bloco B. Sem banco, sem I/O — TaxRule montada em
|
||||
memória. Os dois perfis REAIS da Thiago (extração do iCode,
|
||||
thoughts/2026-07-13-icode-config-fiscal-extraida.md) são casos de teste
|
||||
literais: CSOSN 102 → 5102/6102; CSOSN 500 → 5405/6403."""
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from sowai_fiscal.resolver import (
|
||||
AmbiguousRuleError,
|
||||
FiscalConfigError,
|
||||
FiscalItem,
|
||||
FiscalOperation,
|
||||
resolve_fiscal,
|
||||
)
|
||||
|
||||
_PROFILE = uuid.uuid4()
|
||||
_ORG = uuid.uuid4()
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeTaxRule:
|
||||
"""Satisfaz `sowai_fiscal.types.TaxRuleLike` por estrutura — o
|
||||
substituto, nos testes da lib, do ORM `TaxRule` que o auto usa."""
|
||||
|
||||
id: uuid.UUID
|
||||
organization_id: uuid.UUID
|
||||
tax_profile_id: uuid.UUID
|
||||
tax_domain: str
|
||||
crt: str | None = None
|
||||
uf_destino_tipo: str | None = None
|
||||
consumidor_final: bool | None = None
|
||||
indicador_ie: str | None = None
|
||||
tipo_operacao: str | None = None
|
||||
ncm_prefix: str | None = None
|
||||
cest: str | None = None
|
||||
cst: str | None = None
|
||||
csosn: str | None = None
|
||||
cfop: str | None = None
|
||||
base_calc_percent: Decimal | None = None
|
||||
aliquota: Decimal | None = None
|
||||
mva: Decimal | None = None
|
||||
aliquota_st: Decimal | None = None
|
||||
fcp_percent: Decimal | None = None
|
||||
codigo_beneficio: str | None = None
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
def _rule(**kw) -> FakeTaxRule:
|
||||
defaults = dict(
|
||||
id=uuid.uuid4(), organization_id=_ORG, tax_profile_id=_PROFILE,
|
||||
)
|
||||
return FakeTaxRule(**{**defaults, **kw})
|
||||
|
||||
|
||||
def _op(**kw) -> FiscalOperation:
|
||||
defaults = dict(
|
||||
crt="1", uf_origem="PR", uf_destino="PR",
|
||||
indicador_ie="nao_contribuinte", consumidor_final=True,
|
||||
tipo_operacao="venda",
|
||||
item=FiscalItem(
|
||||
tax_profile_id=_PROFILE, ncm="87089990", cest=None, origem="0",
|
||||
quantity=Decimal("2"), unit_price=Decimal("75.00"),
|
||||
),
|
||||
)
|
||||
return FiscalOperation(**{**defaults, **kw})
|
||||
|
||||
|
||||
_PADRAO = [ # preset "Padrão" da Thiago (CSOSN 102)
|
||||
_rule(tax_domain="icms", crt="1", csosn="102", cfop="5102", aliquota=Decimal("0")),
|
||||
_rule(tax_domain="pis", cst="08", aliquota=Decimal("0")),
|
||||
_rule(tax_domain="cofins", cst="08", aliquota=Decimal("0")),
|
||||
]
|
||||
|
||||
|
||||
def test_padrao_interna_resolves_5102_csosn_102():
|
||||
result = resolve_fiscal(_PADRAO, _op())
|
||||
assert result.cfop == "5102"
|
||||
assert result.csosn == "102"
|
||||
assert result.cst is None
|
||||
assert result.origem == "0" # ecoado do ITEM (Part), nunca de regra
|
||||
icms = next(t for t in result.tributos if t.tax_domain == "icms")
|
||||
assert icms.base_calc == Decimal("150.00")
|
||||
assert icms.valor == Decimal("0.00")
|
||||
assert icms.rule_id == _PADRAO[0].id
|
||||
|
||||
|
||||
def test_padrao_interestadual_transposes_to_6102():
|
||||
result = resolve_fiscal(_PADRAO, _op(uf_destino="SP"))
|
||||
assert result.cfop == "6102"
|
||||
|
||||
|
||||
def test_st_profile_resolves_5405_and_6403():
|
||||
st_rules = [_rule(tax_domain="icmsst", crt="1", csosn="500", cfop="5405")]
|
||||
assert resolve_fiscal(st_rules, _op()).cfop == "5405"
|
||||
assert resolve_fiscal(st_rules, _op(uf_destino="SP")).cfop == "6403" # NÃO 6405
|
||||
|
||||
|
||||
def test_more_selective_ncm_rule_beats_generic_rule_with_more_matchers():
|
||||
# A correção do review: NCM (peso 64) vence 3 matchers genéricos
|
||||
# (crt=1 + uf=4 + indicador_ie=8 = 13).
|
||||
generic = _rule(
|
||||
tax_domain="icms", crt="1", uf_destino_tipo="interna",
|
||||
indicador_ie="nao_contribuinte", csosn="102", cfop="5102",
|
||||
)
|
||||
by_ncm = _rule(tax_domain="icms", ncm_prefix="8708", csosn="500", cfop="5405")
|
||||
result = resolve_fiscal([generic, by_ncm], _op())
|
||||
assert result.csosn == "500"
|
||||
|
||||
|
||||
def test_rule_with_non_matching_matcher_is_skipped():
|
||||
rule_sp = _rule(tax_domain="icms", uf_destino_tipo="interestadual", csosn="102", cfop="5102")
|
||||
with pytest.raises(FiscalConfigError):
|
||||
resolve_fiscal([rule_sp], _op()) # operação interna; a regra não casa
|
||||
|
||||
|
||||
def test_tie_same_weight_raises_ambiguous():
|
||||
a = _rule(tax_domain="icms", crt="1", csosn="102", cfop="5102")
|
||||
b = _rule(tax_domain="icms", crt="1", csosn="101", cfop="5102")
|
||||
with pytest.raises(AmbiguousRuleError) as exc:
|
||||
resolve_fiscal([a, b], _op())
|
||||
assert {a.id, b.id} == set(exc.value.rule_ids)
|
||||
|
||||
|
||||
def test_fail_closed_lists_the_missing_domain():
|
||||
with pytest.raises(FiscalConfigError) as exc:
|
||||
resolve_fiscal([], _op())
|
||||
assert "icms" in " ".join(exc.value.missing)
|
||||
|
||||
|
||||
def test_optional_domain_absent_does_not_block():
|
||||
# Sem regra de FCP/IPI → simplesmente não entra no resultado.
|
||||
result = resolve_fiscal(_PADRAO, _op())
|
||||
assert {t.tax_domain for t in result.tributos} == {"icms", "pis", "cofins"}
|
||||
|
||||
|
||||
def test_st_value_math_with_mva():
|
||||
# base 100; MVA 40% → base_st 140; aliq_st 18% → 25.20 − ICMS próprio 12.00 = 13.20
|
||||
rules = [
|
||||
_rule(tax_domain="icms", cst="00", cfop="5102", aliquota=Decimal("12")),
|
||||
_rule(
|
||||
tax_domain="icmsst", cst="10", cfop="5405",
|
||||
mva=Decimal("40"), aliquota_st=Decimal("18"),
|
||||
),
|
||||
]
|
||||
op = _op(crt="3", item=FiscalItem(
|
||||
tax_profile_id=_PROFILE, ncm="27101259", cest="0600100", origem="0",
|
||||
quantity=Decimal("1"), unit_price=Decimal("100.00"),
|
||||
))
|
||||
result = resolve_fiscal(rules, op)
|
||||
st = next(t for t in result.tributos if t.tax_domain == "icmsst")
|
||||
assert st.valor == Decimal("13.20")
|
||||
|
||||
|
||||
def test_reducao_de_base():
|
||||
# base 100 com redução p/ 60% e alíquota 18% → 10.80
|
||||
rules = [_rule(
|
||||
tax_domain="icms", cst="20", cfop="5102",
|
||||
base_calc_percent=Decimal("60"), aliquota=Decimal("18"),
|
||||
)]
|
||||
result = resolve_fiscal(rules, _op(crt="3", item=FiscalItem(
|
||||
tax_profile_id=_PROFILE, ncm="87089990", cest=None, origem="0",
|
||||
quantity=Decimal("1"), unit_price=Decimal("100.00"),
|
||||
)))
|
||||
icms = next(t for t in result.tributos if t.tax_domain == "icms")
|
||||
assert icms.valor == Decimal("10.80")
|
||||
|
||||
|
||||
def test_anchor_matches_without_cfop_names_the_field():
|
||||
"""M5 (review 2026-07-15): a âncora casa (tem csosn) mas não tem CFOP --
|
||||
`FiscalConfigError.missing` deve nomear o campo faltante, não só o
|
||||
domínio."""
|
||||
rule = _rule(tax_domain="icms", csosn="102", cfop=None)
|
||||
with pytest.raises(FiscalConfigError) as exc:
|
||||
resolve_fiscal([rule], _op())
|
||||
assert "cfop" in " ".join(exc.value.missing)
|
||||
|
||||
|
||||
def test_anchor_matches_without_cst_or_csosn_names_the_field():
|
||||
"""M5 (review 2026-07-15): a âncora casa (tem cfop) mas não tem cst NEM
|
||||
csosn -- `FiscalConfigError.missing` deve nomear cst/csosn."""
|
||||
rule = _rule(tax_domain="icms", cfop="5102", cst=None, csosn=None)
|
||||
with pytest.raises(FiscalConfigError) as exc:
|
||||
resolve_fiscal([rule], _op())
|
||||
assert "cst/csosn" in " ".join(exc.value.missing)
|
||||
|
||||
|
||||
def test_deleted_rule_is_ignored():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
dead = _rule(tax_domain="icms", csosn="102", cfop="5102",
|
||||
deleted_at=datetime.now(timezone.utc))
|
||||
with pytest.raises(FiscalConfigError):
|
||||
resolve_fiscal([dead], _op())
|
||||
|
||||
|
||||
def test_fcp_calculates_from_fcp_percent():
|
||||
"""I1 (auditoria Fable Bloco B): o domínio `fcp` não tem `aliquota` no
|
||||
catálogo (só `fcp_percent`, DOMAIN_FIELDS['fcp']) -- `_linha` genérico
|
||||
só multiplica `aliquota`, então toda linha FCP saía com `valor=0.00`
|
||||
(preview que mente). Prova de detecção: SEM o fix deste teste, `valor`
|
||||
vem 0.00 em vez de 2.00 -- FCP 2% sobre base 100."""
|
||||
rules = [
|
||||
*_PADRAO,
|
||||
_rule(tax_domain="fcp", fcp_percent=Decimal("2")),
|
||||
]
|
||||
op = _op(item=FiscalItem(
|
||||
tax_profile_id=_PROFILE, ncm="87089990", cest=None, origem="0",
|
||||
quantity=Decimal("1"), unit_price=Decimal("100.00"),
|
||||
))
|
||||
result = resolve_fiscal(rules, op)
|
||||
fcp = next(t for t in result.tributos if t.tax_domain == "fcp")
|
||||
assert fcp.valor == Decimal("2.00")
|
||||
assert fcp.fcp_percent == Decimal("2")
|
||||
@@ -0,0 +1,303 @@
|
||||
"""1b.1 Task 5: `xml_builder.build_nfe` -- montagem PURA do XML da NF-e 55
|
||||
(NT 2025.002 v1.40). Todos os testes montam `DadosEmissao` em memória
|
||||
(nenhum banco, nenhum I/O) e inspecionam o objeto `Nfe` resultante
|
||||
diretamente OU sua serialização via `xsdata.formats.dataclass.serializers.
|
||||
XmlSerializer` (o mesmo serializador que `emissao.py`, Task 6, usará antes
|
||||
de assinar)."""
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from xsdata.formats.dataclass.serializers import XmlSerializer
|
||||
from xsdata.formats.dataclass.serializers.config import SerializerConfig
|
||||
|
||||
from sowai_fiscal.resolver import FiscalResult, TributoLinha
|
||||
from sowai_fiscal.xml_builder import (
|
||||
TEXTO_HOMOLOGACAO,
|
||||
DadosEmissao,
|
||||
DestinatarioData,
|
||||
EmitenteData,
|
||||
ItemData,
|
||||
PagamentoData,
|
||||
build_nfe,
|
||||
)
|
||||
|
||||
_NFE_NAMESPACE = "http://www.portalfiscal.inf.br/nfe"
|
||||
|
||||
|
||||
def _emitente(**overrides) -> EmitenteData:
|
||||
base = dict(
|
||||
cnpj="12345678000190",
|
||||
razao_social="EMPRESA TESTE LTDA",
|
||||
nome_fantasia="Empresa Teste",
|
||||
ie="1234567890",
|
||||
crt="1",
|
||||
address_street="Rua Teste",
|
||||
address_number="100",
|
||||
address_complement=None,
|
||||
address_district="Centro",
|
||||
address_city="Curitiba",
|
||||
address_state="PR",
|
||||
address_zip="80000000",
|
||||
address_city_ibge_code="4106902",
|
||||
)
|
||||
base.update(overrides)
|
||||
return EmitenteData(**base)
|
||||
|
||||
|
||||
def _destinatario(**overrides) -> DestinatarioData:
|
||||
base = dict(
|
||||
nome="CLIENTE TESTE LTDA",
|
||||
cnpj="99887766000155",
|
||||
cpf=None,
|
||||
indicador_ie="9",
|
||||
address_street="Av Cliente",
|
||||
address_number="50",
|
||||
address_district="Bairro",
|
||||
address_city="Curitiba",
|
||||
address_state="PR",
|
||||
address_zip="80000000",
|
||||
address_city_ibge_code="4106902",
|
||||
)
|
||||
base.update(overrides)
|
||||
return DestinatarioData(**base)
|
||||
|
||||
|
||||
def _tributo(tax_domain: str, *, cst=None, csosn=None, aliquota=None, valor="0.00", base_calc="100.00") -> TributoLinha:
|
||||
return TributoLinha(
|
||||
tax_domain=tax_domain,
|
||||
cst=cst,
|
||||
csosn=csosn,
|
||||
base_calc=Decimal(base_calc),
|
||||
base_calc_percent=Decimal("100"),
|
||||
aliquota=Decimal(aliquota) if aliquota is not None else None,
|
||||
valor=Decimal(valor),
|
||||
mva=None,
|
||||
aliquota_st=None,
|
||||
fcp_percent=None,
|
||||
codigo_beneficio=None,
|
||||
rule_id=uuid.uuid4(),
|
||||
)
|
||||
|
||||
|
||||
def _fiscal_result(cfop: str, *, csosn="102", cst=None, tributos=None, origem="0") -> FiscalResult:
|
||||
return FiscalResult(
|
||||
cfop=cfop,
|
||||
cst=cst,
|
||||
csosn=csosn,
|
||||
origem=origem,
|
||||
consumidor_final=True,
|
||||
indicador_ie="9",
|
||||
tributos=tributos if tributos is not None else [
|
||||
_tributo("pis", cst="08"),
|
||||
_tributo("cofins", cst="08"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _item(cfop: str, **overrides) -> ItemData:
|
||||
base = dict(
|
||||
codigo="PC001",
|
||||
descricao="Filtro de oleo",
|
||||
ncm="84212300",
|
||||
cfop=cfop,
|
||||
unidade_comercial="UN",
|
||||
unidade_tributavel="UN",
|
||||
quantidade=Decimal("2"),
|
||||
valor_unitario=Decimal("50.00"),
|
||||
fiscal_result=_fiscal_result(cfop),
|
||||
)
|
||||
base.update(overrides)
|
||||
return ItemData(**base)
|
||||
|
||||
|
||||
def _pagamento(**overrides) -> PagamentoData:
|
||||
base = dict(tpag="01", valor=Decimal("100.00"))
|
||||
base.update(overrides)
|
||||
return PagamentoData(**base)
|
||||
|
||||
|
||||
def _dados(**overrides) -> DadosEmissao:
|
||||
base = dict(
|
||||
emitente=_emitente(),
|
||||
itens=[_item("5102")],
|
||||
pagamento=_pagamento(),
|
||||
ambiente="homologacao",
|
||||
chave_acesso="4" * 44,
|
||||
numero=1014,
|
||||
serie=1,
|
||||
cnf="87654321",
|
||||
dh_emi="2026-07-16T10:00:00-03:00",
|
||||
uf_destino_tipo="interna",
|
||||
destinatario=_destinatario(),
|
||||
)
|
||||
base.update(overrides)
|
||||
return DadosEmissao(**base)
|
||||
|
||||
|
||||
def _render(nfe) -> str:
|
||||
config = SerializerConfig(xml_declaration=False, indent=None)
|
||||
return XmlSerializer(config=config).render(nfe, ns_map={None: _NFE_NAMESPACE})
|
||||
|
||||
|
||||
# --- venda Padrão intra-PR ---------------------------------------------
|
||||
|
||||
|
||||
def test_venda_padrao_intra_estado_cfop_5102_csosn_102_e_origem_do_item():
|
||||
dados = _dados(uf_destino_tipo="interna", itens=[_item("5102", fiscal_result=_fiscal_result("5102", csosn="102", origem="1"))])
|
||||
|
||||
nfe = build_nfe(dados)
|
||||
|
||||
det = nfe.infNFe.det[0]
|
||||
assert det.prod.CFOP == "5102"
|
||||
assert det.imposto.ICMS.ICMSSN102.CSOSN == "102"
|
||||
assert det.imposto.ICMS.ICMSSN102.orig == "1" # origem SEMPRE do item, nunca de regra
|
||||
assert nfe.infNFe.ide.idDest == "1"
|
||||
|
||||
|
||||
def test_totais_batem_com_a_soma_dos_itens_decimal_2_casas():
|
||||
itens = [
|
||||
_item("5102", quantidade=Decimal("2"), valor_unitario=Decimal("50.00")),
|
||||
_item("5102", quantidade=Decimal("3"), valor_unitario=Decimal("10.005")),
|
||||
]
|
||||
dados = _dados(itens=itens)
|
||||
|
||||
nfe = build_nfe(dados)
|
||||
|
||||
# 2*50.00 = 100.00 ; 3*10.005 = 30.015 -> arredonda p/ 30.02 (ROUND_HALF_UP)
|
||||
assert nfe.infNFe.total.ICMSTot.vProd == "130.02"
|
||||
assert nfe.infNFe.total.ICMSTot.vNF == "130.02"
|
||||
# Decimal com 2 casas sempre (nunca "100.0" ou "100")
|
||||
assert nfe.infNFe.total.ICMSTot.vProd.count(".") == 1
|
||||
assert len(nfe.infNFe.total.ICMSTot.vProd.split(".")[1]) == 2
|
||||
|
||||
|
||||
# --- interestadual --------------------------------------------------------
|
||||
|
||||
|
||||
def test_venda_interestadual_cfop_6102_iddest_2():
|
||||
dados = _dados(
|
||||
uf_destino_tipo="interestadual",
|
||||
itens=[_item("6102", fiscal_result=_fiscal_result("6102", csosn="102"))],
|
||||
ind_final="1",
|
||||
destinatario=_destinatario(indicador_ie="1", address_state="SP"),
|
||||
)
|
||||
|
||||
nfe = build_nfe(dados)
|
||||
|
||||
det = nfe.infNFe.det[0]
|
||||
assert det.prod.CFOP == "6102"
|
||||
assert nfe.infNFe.ide.idDest == "2"
|
||||
assert nfe.infNFe.ide.indFinal == "1"
|
||||
assert nfe.infNFe.dest.indIEDest == "1"
|
||||
|
||||
|
||||
# --- pagamento -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pag_detpag_tpag_do_pagamento():
|
||||
dados = _dados(pagamento=_pagamento(tpag="03", valor=Decimal("55.50")))
|
||||
|
||||
nfe = build_nfe(dados)
|
||||
|
||||
det_pag = nfe.infNFe.pag.detPag[0]
|
||||
assert det_pag.tPag == "03"
|
||||
assert det_pag.vPag == "55.50"
|
||||
|
||||
|
||||
def test_pag_tpag_17_para_pix():
|
||||
dados = _dados(pagamento=_pagamento(tpag="17", valor=Decimal("100.00")))
|
||||
|
||||
nfe = build_nfe(dados)
|
||||
|
||||
assert nfe.infNFe.pag.detPag[0].tPag == "17"
|
||||
|
||||
|
||||
# --- homologação -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_homologacao_forca_xnome_do_destinatario_para_texto_oficial():
|
||||
dados = _dados(ambiente="homologacao", destinatario=_destinatario(nome="Cliente Real Ltda"))
|
||||
|
||||
nfe = build_nfe(dados)
|
||||
|
||||
assert nfe.infNFe.dest.xNome == TEXTO_HOMOLOGACAO
|
||||
assert nfe.infNFe.ide.tpAmb == "2"
|
||||
|
||||
|
||||
def test_producao_mantem_xnome_real_do_destinatario():
|
||||
dados = _dados(ambiente="producao", destinatario=_destinatario(nome="Cliente Real Ltda"))
|
||||
|
||||
nfe = build_nfe(dados)
|
||||
|
||||
assert nfe.infNFe.dest.xNome == "Cliente Real Ltda"
|
||||
assert nfe.infNFe.ide.tpAmb == "1"
|
||||
|
||||
|
||||
# --- grupo UB (IBS/CBS) -----------------------------------------------------
|
||||
|
||||
|
||||
def test_grupo_ibscbs_ausente_sem_linhas_ibs_cbs_crt1_2026():
|
||||
dados = _dados(itens=[_item("5102", fiscal_result=_fiscal_result("5102"))])
|
||||
|
||||
nfe = build_nfe(dados)
|
||||
|
||||
assert nfe.infNFe.det[0].imposto.IBSCBS is None
|
||||
|
||||
|
||||
def test_grupo_ibscbs_presente_com_linhas_ibs_cbs():
|
||||
fr = _fiscal_result(
|
||||
"5102",
|
||||
tributos=[
|
||||
_tributo("pis", cst="08"),
|
||||
_tributo("cofins", cst="08"),
|
||||
_tributo("ibs", aliquota="0.1000", valor="10.00", base_calc="100.00"),
|
||||
_tributo("cbs", aliquota="0.9000", valor="90.00", base_calc="100.00"),
|
||||
],
|
||||
)
|
||||
dados = _dados(itens=[_item("5102", fiscal_result=fr)])
|
||||
|
||||
nfe = build_nfe(dados)
|
||||
|
||||
ibscbs = nfe.infNFe.det[0].imposto.IBSCBS
|
||||
assert ibscbs is not None
|
||||
assert ibscbs.gIBSCBS.gIBSUF.vIBSUF == "10.00"
|
||||
assert ibscbs.gIBSCBS.gCBS.vCBS == "90.00"
|
||||
|
||||
|
||||
# --- serialização -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_serializacao_gera_xml_bem_formado_com_namespace_correto():
|
||||
dados = _dados()
|
||||
|
||||
nfe = build_nfe(dados)
|
||||
xml = _render(nfe)
|
||||
|
||||
assert xml.startswith(f'<NFe xmlns="{_NFE_NAMESPACE}">')
|
||||
assert f'Id="NFe{dados.chave_acesso}"' in xml
|
||||
assert "<infNFe" in xml and "</infNFe>" in xml
|
||||
# bem-formado: um parser XML real não estoura
|
||||
from lxml import etree
|
||||
|
||||
etree.fromstring(xml.encode("utf-8"))
|
||||
|
||||
|
||||
def test_unsupported_icms_situation_raises_clear_error():
|
||||
from sowai_fiscal.xml_builder import UnsupportedIcmsSituationError
|
||||
|
||||
dados = _dados(itens=[_item("5102", fiscal_result=_fiscal_result("5102", csosn="201"))])
|
||||
|
||||
with pytest.raises(UnsupportedIcmsSituationError):
|
||||
build_nfe(dados)
|
||||
|
||||
|
||||
def test_origem_none_levanta_valueerror_sem_fallback_silencioso():
|
||||
"""I3 (review Opus, 2026-07-16): origem None chegando no builder é erro
|
||||
de PROGRAMAÇÃO (a validação de completude da emissão barra peça sem
|
||||
`icms_origem` com 409 antes) -- jamais um default '0' silencioso, que
|
||||
emitiria origem nacional para peça importada (imposto errado)."""
|
||||
dados = _dados(itens=[_item("5102", fiscal_result=_fiscal_result("5102", origem=None))])
|
||||
|
||||
with pytest.raises(ValueError, match="origem"):
|
||||
build_nfe(dados)
|
||||
Reference in New Issue
Block a user