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,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