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:
jonatanritter
2026-07-22 15:05:10 -03:00
commit 032151af34
17 changed files with 2413 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""sowai_fiscal — núcleo fiscal puro da SowAI (motor de regras, chave de
acesso, builder de XML NF-e). Ver README.md para o mapa dos módulos."""
+102
View File
@@ -0,0 +1,102 @@
"""Chave de acesso da NF-e (funções PURAS -- zero I/O, zero sessão).
Estrutura (44 dígitos): cUF(2) + AAMM(4) + CNPJ(14) + mod(2) + série(3) +
nNF(9) + tpEmis(1) + cNF(8) + DV(1).
cNF é sorteado UMA vez por documento e PERSISTE (`FiscalDocument.
codigo_numerico`, Task 4): retry de transmissão usa a MESMA chave --
re-sortear cNF é o que fabrica a Rejeição 539 (duplicidade com chave
diferente). Regra NT 2019.001: cNF != nNF.
"""
import secrets
_PESOS = (2, 3, 4, 5, 6, 7, 8, 9)
def dv_modulo11(chave43: str) -> str:
"""Dígito verificador módulo 11 da chave de acesso.
Pesos 2..9 aplicados da direita para a esquerda (repetindo em ciclos de
8); soma dos produtos; resto = soma % 11; DV = 0 se resto in (0, 1),
senão DV = 11 - resto. Validado contra chaves reais -- ver
`tests/modules/fiscal/test_chave_acesso.py`.
"""
if len(chave43) != 43 or not chave43.isdigit():
raise ValueError("chave sem DV deve ter 43 dígitos numéricos")
soma = 0
for i, digito in enumerate(reversed(chave43)):
soma += int(digito) * _PESOS[i % 8]
resto = soma % 11
return "0" if resto in (0, 1) else str(11 - resto)
def gerar_cnf(numero_nnf: int) -> str:
"""Sorteia o código numérico (cNF) de 8 dígitos, criptograficamente
aleatório, garantindo cNF != nNF (regra NT 2019.001 -- SEFAZ rejeita
quando os dois coincidem).
F3a (review 2026-07-16): também rejeita `cNF == "00000000"`. All-zeros
é um valor técnicamente válido pelo formato (8 dígitos), mas é o chute
óbvio de qualquer gerador ingênuo/mockado -- aceitá-lo aqui abriria
espaço para uma chave previsível se algum outro ponto do código algum
dia trocasse `secrets.randbelow` por algo mais fraco sem essa rede de
segurança. Nunca colide com `numero_nnf` de propósito (nNF sempre >= 1,
nunca 0 -- `montar_chave_acesso` já exige `numero >= 1`), então esta
checagem nunca é a mesma da de `cNF != nNF` acima."""
while True:
cnf = f"{secrets.randbelow(100_000_000):08d}"
if int(cnf) != numero_nnf and int(cnf) != 0:
return cnf
def montar_chave_acesso(
*,
uf_ibge: str,
aamm: str,
cnpj: str,
modelo: str,
serie: int,
numero: int,
tp_emis: str,
cnf: str,
) -> str:
"""Monta a chave de acesso de 44 dígitos a partir dos componentes já
formatados/validados pelo chamador (o orquestrador de emissão, Task 6).
Pura: não consulta banco, não decide nenhum dos valores -- só concatena
e calcula o DV.
F1 (review 2026-07-16): valida CADA componente individualmente (tamanho
exato + só dígitos), não apenas o tamanho AGREGADO de 43 dígitos do
`chave43` final. Só checar o total é insuficiente -- dois componentes
errados que se COMPENSAM em dígitos (ex.: um `cnpj` de 13 dígitos + um
`cnf` de 9, em vez dos 14+8 corretos) somam exatamente 43 e passavam
batendo pela checagem antiga, produzindo uma chave de 44 dígitos
SINTATICAMENTE válida (DV correto) mas que aponta para o CNPJ/emitente
ERRADO -- um bug silencioso que só se manifestaria como uma rejeição
(ou pior, uma autorização indevida) na SEFAZ, não em teste algum. Cada
checagem abaixo isola o componente específico que a violaria."""
if len(uf_ibge) != 2 or not uf_ibge.isdigit():
raise ValueError(f"uf_ibge deve ter 2 dígitos numéricos: {uf_ibge!r}")
if len(aamm) != 4 or not aamm.isdigit():
raise ValueError(f"aamm deve ter 4 dígitos numéricos: {aamm!r}")
if len(cnpj) != 14 or not cnpj.isdigit():
raise ValueError(f"cnpj deve ter 14 dígitos numéricos: {cnpj!r}")
if len(modelo) != 2 or not modelo.isdigit():
raise ValueError(f"modelo deve ter 2 dígitos numéricos: {modelo!r}")
if len(tp_emis) != 1 or not tp_emis.isdigit():
raise ValueError(f"tp_emis deve ter 1 dígito numérico: {tp_emis!r}")
if len(cnf) != 8 or not cnf.isdigit():
raise ValueError(f"cnf deve ter 8 dígitos numéricos: {cnf!r}")
if not (0 <= serie <= 999):
raise ValueError(f"serie fora do intervalo 0-999: {serie}")
if not (1 <= numero <= 999_999_999):
raise ValueError(f"numero fora do intervalo 1-999999999: {numero}")
chave43 = f"{uf_ibge}{aamm}{cnpj}{modelo}{serie:03d}{numero:09d}{tp_emis}{cnf}"
# Invariante garantido pelas checagens de componente acima -- mantido
# como cinto-e-suspensório (nunca deve disparar; se disparar, é sinal
# de que uma checagem de componente ficou desalinhada com o format
# string abaixo dela).
if len(chave43) != 43:
raise ValueError(f"componentes somam {len(chave43)} dígitos, esperado 43")
return chave43 + dv_modulo11(chave43)
+140
View File
@@ -0,0 +1,140 @@
"""Catálogo de domínios tributários do motor fiscal (Bloco B).
`tax_domain` é `String(20)` no banco DE PROPÓSITO — validado por ESTE enum
Python na borda Pydantic. Adicionar um tributo (IBS/CBS/qualquer futuro) é
um membro novo aqui + uma entrada em DOMAIN_FIELDS: sem ALTER TYPE, sem
migration, sem o gotcha enum-NAME-vs-value que já quebrou produção neste
projeto (ver project_enum_migration_gotcha). Este é o "imposto-como-dado"
literal da Opção 1 do spec.
"""
import enum
from dataclasses import dataclass
class TaxDomain(str, enum.Enum):
ICMS = "icms"
ICMSST = "icmsst"
IPI = "ipi"
PIS = "pis"
COFINS = "cofins"
DIFAL = "difal"
FCP = "fcp"
IBS = "ibs"
CBS = "cbs"
ISS = "iss"
@dataclass(frozen=True)
class DomainFieldSpec:
"""O que o form da UI mostra/exige para uma regra deste domínio.
Nomes de campo = colunas de resultado da TaxRule. O frontend NÃO
hard-coda isto — consome via GET /fiscal/tax-domains.
`campos_um_de`: grupos "exatamente um de" (review do frontend,
2026-07-15) — para ICMS/ICMS-ST a situação tributária é exatamente um
de {cst, csosn} (qual, depende do CRT da filial: Simples → CSOSN,
Normal → CST). Sem isto no catálogo, o form dinâmico não tem como
exigir situação tributária e o usuário come um 422 que o form não
previu. O validator das regras consome ESTES grupos — uma fonte só.
"""
label: str
campos_aplicaveis: tuple[str, ...]
campos_obrigatorios: tuple[str, ...]
campos_um_de: tuple[tuple[str, ...], ...] = ()
_SITUACAO = ("cst", "csosn") # exatamente um dos dois, conforme o CRT
_VALORES = ("base_calc_percent", "aliquota")
DOMAIN_FIELDS: dict[str, DomainFieldSpec] = {
TaxDomain.ICMS.value: DomainFieldSpec(
label="ICMS",
campos_aplicaveis=(*_SITUACAO, "cfop", *_VALORES, "codigo_beneficio"),
campos_obrigatorios=("cfop",),
campos_um_de=(_SITUACAO,),
),
TaxDomain.ICMSST.value: DomainFieldSpec(
label="ICMS-ST",
campos_aplicaveis=(*_SITUACAO, "cfop", *_VALORES, "mva", "aliquota_st", "codigo_beneficio"),
campos_obrigatorios=("cfop",),
campos_um_de=(_SITUACAO,),
),
TaxDomain.IPI.value: DomainFieldSpec(
label="IPI",
campos_aplicaveis=("cst", *_VALORES, "codigo_beneficio"),
campos_obrigatorios=("cst",),
),
TaxDomain.PIS.value: DomainFieldSpec(
label="PIS", campos_aplicaveis=("cst", *_VALORES), campos_obrigatorios=("cst",),
),
TaxDomain.COFINS.value: DomainFieldSpec(
label="COFINS", campos_aplicaveis=("cst", *_VALORES), campos_obrigatorios=("cst",),
),
TaxDomain.DIFAL.value: DomainFieldSpec(
label="DIFAL", campos_aplicaveis=_VALORES, campos_obrigatorios=("aliquota",),
),
TaxDomain.FCP.value: DomainFieldSpec(
label="FCP", campos_aplicaveis=("fcp_percent",), campos_obrigatorios=("fcp_percent",),
),
# Reforma tributária: domínios ACEITOS hoje (regra pode ser cadastrada),
# cálculo entra no roadmap R1 do spec quando SEFAZ publicar o leiaute.
TaxDomain.IBS.value: DomainFieldSpec(
label="IBS", campos_aplicaveis=_VALORES, campos_obrigatorios=("aliquota",),
),
TaxDomain.CBS.value: DomainFieldSpec(
label="CBS", campos_aplicaveis=_VALORES, campos_obrigatorios=("aliquota",),
),
TaxDomain.ISS.value: DomainFieldSpec(
label="ISS", campos_aplicaveis=_VALORES, campos_obrigatorios=("aliquota",),
),
}
# Especificidade ponderada (spec, seção resolvedor): potências de 2 em ordem
# ESTRITA — cada matcher supera a soma de todos os mais fracos (64 > 63),
# então nenhuma combinação de matchers genéricos vence um mais seletivo.
# Caveat registrado no spec: ncm > cest; se um preset futuro criar regra-NCM
# que sombreie regra-CEST-de-ST, revisitar estes dois.
#
# M5 (auditoria Fable Bloco B) — pegadinha dos prefixos NCM ANINHADOS: o
# peso de `ncm_prefix` é fixo (64) independente do COMPRIMENTO do prefixo —
# uma regra com `ncm_prefix="87"` e outra com `ncm_prefix="8708"` casando a
# MESMA peça (NCM 87089990) EMPATAM em peso (ambas ganham 64), mesmo a
# segunda sendo estritamente mais específica. Isto é AMBIGUIDADE DELIBERADA
# (vira `AmbiguousRuleError`, fail-closed, nunca escolha silenciosa do
# prefixo mais longo) — o resolvedor NÃO faz "longest-prefix-wins" como um
# roteador de IP faria. A UI de cadastro de regras deve orientar o tenant a
# usar prefixos NCM DISJUNTOS (não aninhados) dentro do mesmo domínio/
# situação, ou aceitar o 409 de ambiguidade como sinal de conflito a
# resolver manualmente.
MATCHER_WEIGHTS: dict[str, int] = {
"ncm_prefix": 64,
"cest": 32,
"consumidor_final": 16,
"indicador_ie": 8,
"uf_destino_tipo": 4,
"tipo_operacao": 2,
"crt": 1,
}
# Transposição CFOP intra→interestadual. A tabela explícita existe porque a
# regra geral (trocar 5→6 / 1→2) está ERRADA para ST: 5405 → 6403, não 6405.
# CFOPs de ST/exceção DEVEM estar aqui; o fallback genérico cobre o resto.
TRANSPOSICAO_CFOP: dict[str, str] = {
"5102": "6102",
"5405": "6403",
"1202": "2202",
}
_GENERIC_FIRST_DIGIT = {"5": "6", "1": "2"}
def transpose_cfop(cfop: str) -> str:
"""CFOP-base intra-estadual → interestadual (requisito herdado #3 da 1b)."""
if cfop in TRANSPOSICAO_CFOP:
return TRANSPOSICAO_CFOP[cfop]
first = cfop[0]
if first in _GENERIC_FIRST_DIGIT:
return _GENERIC_FIRST_DIGIT[first] + cfop[1:]
return cfop
+98
View File
@@ -0,0 +1,98 @@
"""Presets fiscais seed-editáveis (decisão batida #3/#4 do spec): as regras
são COPIADAS como TaxRule reais do tenant, que edita/apaga à vontade.
Valores REAIS extraídos do iCode da Thiago
(thoughts/2026-07-13-icode-config-fiscal-extraida.md) — perfis "Padrão"
(cód 110) e "Óleos/ST" (cód 108). NÃO inventar valores aqui.
PIS/COFINS do Óleos: CST 04 (monofásico — revenda a alíquota zero). O
`~49/52` da extração tinha um til de incerteza; óleo lubrificante revendido
é monofásico POR LEGISLAÇÃO (não é regra do cliente), logo CST 04.
⚠️ Confirmar contra a aba PIS/COFINS do perfil 108 no iCode antes do
primeiro tenant novo usar este preset em emissão real (tarefa registrada).
NENHUM preset carrega `origem`: origem é sempre do Part (review do
frontend, 2026-07-14).
"""
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass(frozen=True)
class PresetRule:
tax_domain: str
crt: str | None = None
tipo_operacao: str | None = None
cst: str | None = None
csosn: str | None = None
cfop: str | None = None
aliquota: Decimal | None = None
@dataclass(frozen=True)
class FiscalPreset:
key: str
name: str
description: str
# M2 (auditoria Fable Bloco B): CRT-alvo do preset ("1" = Simples
# Nacional para os dois presets extraídos do iCode) -- exposto no
# catálogo (`GET /fiscal/presets`) pra UI avisar quando o CRT da filial
# não bate com o preset que o tenant está aplicando.
regime_alvo: str = "1"
rules: tuple[PresetRule, ...] = field(default_factory=tuple)
PRESETS: dict[str, FiscalPreset] = {
"autopecas_simples_padrao": FiscalPreset(
key="autopecas_simples_padrao",
name="Autopeças — Simples Nacional (Padrão)",
description=(
"Venda de peças no Simples: CSOSN 102, CFOP 5102 (interno; o motor "
"transpõe para 6102 fora do estado), PIS/COFINS CST 08 alíquota 0, "
"IPI não destacado. Devolução (entrada): CSOSN 102, CFOP 1202/2202."
),
rules=(
# ICMS escopado a `tipo_operacao="venda"` (review 2026-07-15,
# IMPORTANTE 1): sem escopo, esta regra também casava devolução
# (tipo_operacao=NULL casa qualquer) e devolvia CFOP de SAÍDA
# (5102/6102) para uma operação de ENTRADA — fail-open num motor
# fail-closed. A regra de devolução abaixo é a única que cobre
# tipo_operacao="devolucao" para este preset.
PresetRule(
tax_domain="icms", crt="1", tipo_operacao="venda",
csosn="102", cfop="5102", aliquota=Decimal("0"),
),
PresetRule(
tax_domain="icms", crt="1", tipo_operacao="devolucao",
csosn="102", cfop="1202", aliquota=Decimal("0"),
),
# PIS/COFINS ficam sem escopo de tipo_operacao: os mesmos CSTs
# valem para venda e devolução.
PresetRule(tax_domain="pis", cst="08", aliquota=Decimal("0")),
PresetRule(tax_domain="cofins", cst="08", aliquota=Decimal("0")),
),
),
"autopecas_simples_st": FiscalPreset(
key="autopecas_simples_st",
name="Autopeças — Simples Nacional (Óleos/ST)",
description=(
"Peça/óleo com ICMS-ST retido pelo fornecedor: CSOSN 500, CFOP 5405 "
"(interno; 6403 fora do estado), PIS/COFINS CST 04 (monofásico). "
"Devolução (entrada): CSOSN 500, CFOP 1202/2202."
),
rules=(
# Mesmo escopo de tipo_operacao que o preset Padrão — ver
# comentário acima.
PresetRule(
tax_domain="icmsst", crt="1", tipo_operacao="venda",
csosn="500", cfop="5405",
),
PresetRule(
tax_domain="icmsst", crt="1", tipo_operacao="devolucao",
csosn="500", cfop="1202",
),
PresetRule(tax_domain="pis", cst="04", aliquota=Decimal("0")),
PresetRule(tax_domain="cofins", cst="04", aliquota=Decimal("0")),
),
),
}
+227
View File
@@ -0,0 +1,227 @@
"""Resolvedor fiscal puro do Bloco B: FiscalOperation × TaxRule → FiscalResult.
Função pura, determinística, ZERO I/O — recebe as regras já carregadas.
Quem toca o banco é fiscal/service.py. Fail-closed: sem regra para um
domínio OBRIGATÓRIO (icms/icmsst com CFOP + situação tributária), levanta
FiscalConfigError — nunca emite imposto adivinhado (decisão batida #2 do
spec). `origem` é ecoada do ITEM (Part.icms_origem) e jamais de regra.
"""
import uuid
from decimal import ROUND_HALF_UP, Decimal
from pydantic import BaseModel
from sowai_fiscal.domains import MATCHER_WEIGHTS, TaxDomain, transpose_cfop
from sowai_fiscal.types import TaxRuleLike
_CENT = Decimal("0.01")
class FiscalItem(BaseModel):
tax_profile_id: uuid.UUID
ncm: str
cest: str | None
origem: str | None
# M1 (auditoria Fable Bloco B): input completo que o spec lista (costura
# do R4/ISS) -- nenhuma regra o consome hoje, ecoado do
# `Part.tipo_fiscal` pelo simulate.
tipo_fiscal: str | None = None
quantity: Decimal
unit_price: Decimal
class FiscalOperation(BaseModel):
crt: str
uf_origem: str
uf_destino: str
indicador_ie: str
consumidor_final: bool
tipo_operacao: str
item: FiscalItem
@property
def uf_destino_tipo(self) -> str:
return "interna" if self.uf_origem == self.uf_destino else "interestadual"
class TributoLinha(BaseModel):
tax_domain: str
cst: str | None
csosn: str | None
base_calc: Decimal
base_calc_percent: Decimal
aliquota: Decimal | None
valor: Decimal
mva: Decimal | None
aliquota_st: Decimal | None
fcp_percent: Decimal | None
codigo_beneficio: str | None
rule_id: uuid.UUID
class FiscalResult(BaseModel):
cfop: str
cst: str | None
csosn: str | None
origem: str | None
consumidor_final: bool
indicador_ie: str
tributos: list[TributoLinha]
class FiscalConfigError(Exception):
"""Fail-closed: configuração fiscal faltante — a emissão DEVE bloquear."""
def __init__(self, missing: list[str]):
self.missing = missing
super().__init__(
"Configuração fiscal ausente para: " + ", ".join(missing)
)
class AmbiguousRuleError(Exception):
"""Duas regras casam com o MESMO peso — erro de configuração, nunca
escolha silenciosa (decisão do spec, review do frontend)."""
def __init__(self, tax_domain: str, rule_ids: list[uuid.UUID]):
self.tax_domain = tax_domain
self.rule_ids = rule_ids
super().__init__(
f"Regras ambíguas para o domínio {tax_domain!r}: "
+ ", ".join(str(r) for r in rule_ids)
)
def _matches(rule: TaxRuleLike, op: FiscalOperation) -> int | None:
"""Peso da regra para a operação, ou None se algum matcher não casa."""
weight = 0
checks: list[tuple[str, bool]] = [
("crt", rule.crt is None or rule.crt == op.crt),
("uf_destino_tipo", rule.uf_destino_tipo is None or rule.uf_destino_tipo == op.uf_destino_tipo),
("consumidor_final", rule.consumidor_final is None or rule.consumidor_final == op.consumidor_final),
("indicador_ie", rule.indicador_ie is None or rule.indicador_ie == op.indicador_ie),
("tipo_operacao", rule.tipo_operacao is None or rule.tipo_operacao == op.tipo_operacao),
("ncm_prefix", rule.ncm_prefix is None or op.item.ncm.startswith(rule.ncm_prefix)),
("cest", rule.cest is None or rule.cest == op.item.cest),
]
for campo, ok in checks:
if not ok:
return None
if getattr(rule, campo) is not None:
weight += MATCHER_WEIGHTS[campo]
return weight
def _pick(rules: list[TaxRuleLike], domain: str, op: FiscalOperation) -> TaxRuleLike | None:
candidates: list[tuple[int, TaxRuleLike]] = []
for rule in rules:
if rule.tax_domain != domain or rule.deleted_at is not None:
continue
weight = _matches(rule, op)
if weight is not None:
candidates.append((weight, rule))
if not candidates:
return None
top = max(w for w, _ in candidates)
winners = [r for w, r in candidates if w == top]
if len(winners) > 1:
raise AmbiguousRuleError(domain, [r.id for r in winners])
return winners[0]
def _linha(rule: TaxRuleLike, base: Decimal) -> TributoLinha:
pct = rule.base_calc_percent if rule.base_calc_percent is not None else Decimal("100")
base_efetiva = (base * pct / Decimal("100")).quantize(_CENT, rounding=ROUND_HALF_UP)
aliquota = rule.aliquota
if rule.tax_domain == TaxDomain.FCP.value:
# I1 (auditoria Fable Bloco B): o domínio `fcp` NÃO tem `aliquota`
# no catálogo (DOMAIN_FIELDS['fcp'] só declara `fcp_percent`) — o
# ramo genérico abaixo (aliquota is None → 0.00) fazia toda linha
# FCP sair com valor zerado, um preview que mente. Domínio-explícito
# de propósito: NÃO um fallback genérico "aliquota None usa
# fcp_percent", que surpreenderia outros domínios (ex.: PIS/COFINS
# CST 08/04, onde aliquota None É zero de verdade).
taxa = rule.fcp_percent if rule.fcp_percent is not None else Decimal("0")
valor = (base_efetiva * taxa / Decimal("100")).quantize(_CENT, rounding=ROUND_HALF_UP)
else:
valor = (
(base_efetiva * aliquota / Decimal("100")).quantize(_CENT, rounding=ROUND_HALF_UP)
if aliquota is not None
else Decimal("0.00")
)
return TributoLinha(
tax_domain=rule.tax_domain,
cst=rule.cst, csosn=rule.csosn,
base_calc=base_efetiva, base_calc_percent=pct,
aliquota=aliquota, valor=valor,
mva=rule.mva, aliquota_st=rule.aliquota_st,
fcp_percent=rule.fcp_percent, codigo_beneficio=rule.codigo_beneficio,
rule_id=rule.id,
)
def _linha_st(rule: TaxRuleLike, base: Decimal, valor_icms_proprio: Decimal) -> TributoLinha:
"""ICMS-ST: base_st = base × (1 + MVA%); valor = base_st × aliq_st ICMS próprio.
Para CSOSN 500 (ST retida) mva/aliquota_st são nulos → valor 0, correto."""
linha = _linha(rule, base)
if rule.mva is not None and rule.aliquota_st is not None:
base_st = (base * (Decimal("100") + rule.mva) / Decimal("100")).quantize(
_CENT, rounding=ROUND_HALF_UP
)
bruto = (base_st * rule.aliquota_st / Decimal("100")).quantize(
_CENT, rounding=ROUND_HALF_UP
)
linha = linha.model_copy(
update={"base_calc": base_st, "valor": max(Decimal("0.00"), bruto - valor_icms_proprio)}
)
return linha
def resolve_fiscal(rules: list[TaxRuleLike], op: FiscalOperation) -> FiscalResult:
base = (op.item.quantity * op.item.unit_price).quantize(_CENT, rounding=ROUND_HALF_UP)
# 1. Âncora: ICMS ou ICMS-ST — deve existir, com CFOP + situação.
icms_rule = _pick(rules, TaxDomain.ICMS.value, op)
icmsst_rule = _pick(rules, TaxDomain.ICMSST.value, op)
anchor = icmsst_rule or icms_rule # ST é a situação mais específica quando ambos casam
missing: list[str] = []
if anchor is None:
missing.append("icms (ou icmsst): nenhuma regra casa com a operação")
else:
if anchor.cfop is None:
missing.append(f"cfop na regra {anchor.id} ({anchor.tax_domain})")
if anchor.cst is None and anchor.csosn is None:
missing.append(f"cst/csosn na regra {anchor.id} ({anchor.tax_domain})")
if missing:
raise FiscalConfigError(missing)
# 2. CFOP com transposição (requisito herdado #3).
cfop = anchor.cfop
if op.uf_destino_tipo == "interestadual":
cfop = transpose_cfop(cfop)
# 3. Tributos: uma linha por domínio que tiver regra casando.
tributos: list[TributoLinha] = []
icms_linha = _linha(icms_rule, base) if icms_rule is not None else None
if icms_linha is not None:
tributos.append(icms_linha)
if icmsst_rule is not None:
valor_proprio = icms_linha.valor if icms_linha is not None else Decimal("0.00")
tributos.append(_linha_st(icmsst_rule, base, valor_proprio))
for domain in (
TaxDomain.IPI, TaxDomain.PIS, TaxDomain.COFINS,
TaxDomain.DIFAL, TaxDomain.FCP, TaxDomain.IBS, TaxDomain.CBS, TaxDomain.ISS,
):
rule = _pick(rules, domain.value, op)
if rule is not None:
tributos.append(_linha(rule, base))
return FiscalResult(
cfop=cfop,
cst=anchor.cst,
csosn=anchor.csosn,
origem=op.item.origem, # SEMPRE do item (Part) — nunca de regra
consumidor_final=op.consumidor_final,
indicador_ie=op.indicador_ie,
tributos=tributos,
)
+23
View File
@@ -0,0 +1,23 @@
"""Códigos tPag da NF-e (tabela do MOC, grupo YA02). String no banco,
validação aqui -- um código novo (ex.: futuro meio de pagamento) é uma linha
neste dict, sem migration."""
TPAG_CODES: dict[str, str] = {
"01": "Dinheiro",
"02": "Cheque",
"03": "Cartão de Crédito",
"04": "Cartão de Débito",
"05": "Crédito Loja",
"10": "Vale Alimentação",
"11": "Vale Refeição",
"12": "Vale Presente",
"13": "Vale Combustível",
"14": "Duplicata Mercantil",
"15": "Boleto Bancário",
"16": "Depósito Bancário",
"17": "Pagamento Instantâneo (PIX)",
"18": "Transferência bancária, Carteira Digital",
"19": "Programa de fidelidade, Cashback, Crédito Virtual",
"90": "Sem pagamento",
"99": "Outros",
}
+29
View File
@@ -0,0 +1,29 @@
"""Interface estrutural da regra fiscal — o desacoplamento que permite a lib
existir. O produto passa seus objetos (no auto, o ORM TaxRule) e eles
satisfazem o Protocol por estrutura, sem herdar nada daqui."""
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Protocol
class TaxRuleLike(Protocol):
id: uuid.UUID
tax_domain: str
crt: str | None
uf_destino_tipo: str | None
consumidor_final: bool | None
indicador_ie: str | None
tipo_operacao: str | None
ncm_prefix: str | None
cest: str | None
cst: str | None
csosn: str | None
cfop: str | None
base_calc_percent: Decimal | None
aliquota: Decimal | None
mva: Decimal | None
aliquota_st: Decimal | None
fcp_percent: Decimal | None
codigo_beneficio: str | None
deleted_at: datetime | None
+563
View File
@@ -0,0 +1,563 @@
"""Montagem PURA do XML da NF-e 55 (leiaute NT 2025.002 v1.40) -- 1b.1
Task 5. Recebe um `DadosEmissao` já COMPLETO (o orquestrador de emissão,
`emissao.py`/Task 6, é quem coleta/valida os dados de Branch/Person/Part/
FiscalResult e monta este dataclass) e devolve um objeto `Nfe` (bindings
xsdata do pacote `nfelib`, os MESMOS usados por `estoque/nfe_import.py`
para PARSE -- aqui construímos os mesmos dataclasses em vez de lê-los).
ZERO I/O, ZERO sessão de banco, ZERO decisão de negócio: este módulo não
sabe o que é uma Branch ou uma Sale, só sabe montar o XML a partir dos
campos já resolvidos. Fail-closed (409 `fiscal_config_missing` nomeando
o campo) é responsabilidade do ORQUESTRADOR -- este builder assume que
`DadosEmissao` está completo e, se não estiver (ex.: `None` onde um campo
obrigatório do leiaute era esperado), deixa o próprio xsdata/lxml estourar
uma exceção genérica (AttributeError/TypeError), não um 409 estruturado.
Nomes dos campos vêm do binding REAL instalado (nfelib 2.5.2, inspecionado
diretamente no venv -- `nfelib.nfe.bindings.v4_0.leiaute_nfe_v4_00.Tnfe.
InfNfe` e seus subgrupos -- não chutados): `nfelib.nfe.bindings.v4_0.
nfe_v4_00.Nfe` é a raiz `<NFe>`; `.infNFe` é o `Tnfe.InfNfe`.
Mapeamento de situação tributária ICMS (CST/CSOSN -> grupo do binding):
DELIBERADAMENTE parcial -- cobre exatamente as situações alcançáveis pelos
presets hoje cadastrados (`fiscal.presets`, CSOSN 102/103/300/400 e 500) e
mais alguns CSTs de regime normal (00/40/41/50/60/90) de cobertura óbvia.
Uma situação fora desta tabela levanta `UnsupportedIcmsSituationError` --
erro claro, não um XML silenciosamente errado -- e a tabela deve crescer
quando um preset/regra novo precisar de outra situação.
"""
from dataclasses import dataclass, field
from decimal import ROUND_HALF_UP, Decimal
from nfelib.nfe.bindings.v4_0.leiaute_nfe_v4_00 import Tendereco, TenderEmi, Tnfe
from nfelib.nfe.bindings.v4_0.nfe_v4_00 import Nfe
from sowai_fiscal.resolver import FiscalResult, TributoLinha
_InfNfe = Tnfe.InfNfe
_Det = _InfNfe.Det
_Icms = _Det.Imposto.Icms
_Pis = _Det.Imposto.Pis
_Cofins = _Det.Imposto.Cofins
_CENT = Decimal("0.01")
# Texto oficial (NT NF-e -- ambiente de homologação): substitui o xNome do
# destinatário em toda nota emitida em homologação (tpAmb=2), independente
# do nome real informado.
TEXTO_HOMOLOGACAO = "NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL"
_SEM_GTIN = "SEM GTIN"
class UnsupportedIcmsSituationError(ValueError):
"""CST/CSOSN fora da tabela de mapeamento deste builder (ver docstring
do módulo) -- NÃO é um 409 de dado faltante (o motor resolveu uma
situação válida), é uma lacuna de COBERTURA deste builder. Deve ser
tratado como bug/gap a fechar quando aparecer, não silenciado."""
def __init__(self, cst: str | None, csosn: str | None):
self.cst = cst
self.csosn = csosn
super().__init__(
f"Situação tributária ICMS não suportada pelo builder: cst={cst!r} csosn={csosn!r}"
)
# --- dataclasses de input (montados pelo orquestrador, Task 6) -------------
@dataclass(frozen=True)
class EmitenteData:
cnpj: str
razao_social: str
nome_fantasia: str | None
ie: str
crt: str # "1"|"2"|"3" (CRT.value)
address_street: str
address_number: str
address_complement: str | None
address_district: str
address_city: str
address_state: str
address_zip: str
address_city_ibge_code: str
fone: str | None = None
@dataclass(frozen=True)
class DestinatarioData:
"""`None` no orquestrador == consumidor final não identificado (o
grupo `<dest>` inteiro fica ausente -- opcional no schema)."""
nome: str
cnpj: str | None
cpf: str | None
indicador_ie: str # "1"|"2"|"9" (DestIndIedest)
ie: str | None = None
address_street: str | None = None
address_number: str | None = None
address_complement: str | None = None
address_district: str | None = None
address_city: str | None = None
address_state: str | None = None
address_zip: str | None = None
address_city_ibge_code: str | None = None
email: str | None = None
@dataclass(frozen=True)
class ItemData:
codigo: str # cProd
descricao: str # xProd
ncm: str
cfop: str # já transposto pelo resolvedor (resolve_fiscal)
unidade_comercial: str # uCom
unidade_tributavel: str # uTrib
quantidade: Decimal
valor_unitario: Decimal
fiscal_result: FiscalResult
gtin: str | None = None # None -> "SEM GTIN"
cest: str | None = None
peso_liquido_kg: Decimal | None = None
peso_bruto_kg: Decimal | None = None
@dataclass(frozen=True)
class PagamentoData:
tpag: str
valor: Decimal
indpag: str = "0" # 0=à vista, 1=a prazo
@dataclass(frozen=True)
class DadosEmissao:
emitente: EmitenteData
itens: list[ItemData]
pagamento: PagamentoData
ambiente: str # "homologacao"|"producao"
chave_acesso: str # 44 dígitos, já montada (chave_acesso.montar_chave_acesso)
numero: int
serie: int
cnf: str
dh_emi: str # ISO 8601 com timezone, ex. "2026-07-16T10:00:00-03:00"
uf_destino_tipo: str # "interna"|"interestadual" (mesmo campo do resolvedor)
destinatario: DestinatarioData | None = None
nat_op: str = "Venda"
tp_emis: str = "1"
ind_final: str = "1" # 1 = consumidor final (venda de balcão, o caso comum da 1b.1)
ind_pres: str = "1" # 1 = operação presencial
fin_nfe: str = "1" # 1 = NF-e normal
ver_proc: str = "sowai-auto/1b.1"
# --- mapeamento CST/CSOSN -> grupo ICMS do binding --------------------------
def _pct(linha: TributoLinha | None) -> str:
if linha is None or linha.aliquota is None:
return "0.0000"
return str(linha.aliquota.quantize(Decimal("0.0001")))
def _val(linha: TributoLinha | None) -> str:
if linha is None:
return "0.00"
return str(linha.valor.quantize(_CENT, rounding=ROUND_HALF_UP))
def _base(linha: TributoLinha | None) -> str:
if linha is None:
return "0.00"
return str(linha.base_calc.quantize(_CENT, rounding=ROUND_HALF_UP))
_CSOSN_SEM_PERMISSAO_CREDITO = {"102", "103", "300", "400"}
def _build_icms(fiscal_result: FiscalResult, icms_linha, icmsst_linha) -> _Icms:
csosn = fiscal_result.csosn
cst = fiscal_result.cst
# I3 (review Opus, 2026-07-16): SEM fallback `or "0"` -- origem ausente
# aqui é erro de PROGRAMAÇÃO (a validação de completude do orquestrador,
# `emissao.emitir_documento`, barra peça sem `icms_origem` com 409 antes
# de chegar neste builder), nunca um default silencioso: emitir "0"
# (nacional) para uma peça importada é imposto errado na SEFAZ.
if fiscal_result.origem is None:
raise ValueError(
"fiscal_result.origem é None -- input incompleto para o builder; "
"peça sem icms_origem deveria ter sido barrada na validação de "
"completude da emissão (409 fiscal_config_missing)"
)
orig = fiscal_result.origem
if csosn is not None:
if csosn in _CSOSN_SEM_PERMISSAO_CREDITO:
return _Icms(ICMSSN102=_Icms.Icmssn102(orig=orig, CSOSN=csosn))
if csosn == "500":
return _Icms(
ICMSSN500=_Icms.Icmssn500(
orig=orig,
CSOSN=csosn,
vBCSTRet=_base(icmsst_linha),
pST=_pct(icmsst_linha),
vICMSSTRet=_val(icmsst_linha),
)
)
if csosn == "900":
return _Icms(
ICMSSN900=_Icms.Icmssn900(
orig=orig,
CSOSN=csosn,
modBC="0",
vBC=_base(icms_linha),
pICMS=_pct(icms_linha),
vICMS=_val(icms_linha),
)
)
raise UnsupportedIcmsSituationError(cst, csosn)
if cst is not None:
if cst == "00":
return _Icms(
ICMS00=_Icms.Icms00(
orig=orig, CST=cst, modBC="0",
vBC=_base(icms_linha), pICMS=_pct(icms_linha), vICMS=_val(icms_linha),
)
)
if cst in ("40", "41", "50"):
return _Icms(ICMS40=_Icms.Icms40(orig=orig, CST=cst))
if cst == "60":
return _Icms(
ICMS60=_Icms.Icms60(
orig=orig, CST=cst,
vBCSTRet=_base(icmsst_linha), pST=_pct(icmsst_linha),
vICMSSTRet=_val(icmsst_linha),
)
)
if cst == "90":
return _Icms(
ICMS90=_Icms.Icms90(
orig=orig, CST=cst, modBC="0",
vBC=_base(icms_linha), pICMS=_pct(icms_linha), vICMS=_val(icms_linha),
vBCST=_base(icmsst_linha), pICMSST=_pct(icmsst_linha), vICMSST=_val(icmsst_linha),
)
)
raise UnsupportedIcmsSituationError(cst, csosn)
raise UnsupportedIcmsSituationError(cst, csosn)
# CST 04-09 -> "NT" (não tributado/isento/suspenso -- só CST, sem base/aliq);
# 01/02 -> Aliq (percentual); 03 -> Qtde; qualquer outro -> Outr ("99"),
# mesma tabela para PIS e COFINS (os dois têm as mesmas 4 famílias de grupo).
_PIS_COFINS_NT_CSTS = {"04", "05", "06", "07", "08", "09"}
def _build_pis(linha: TributoLinha | None) -> _Pis:
if linha is None or linha.cst is None:
return _Pis(PISNT=_Pis.Pisnt(CST="08"))
cst = linha.cst
if cst in _PIS_COFINS_NT_CSTS:
return _Pis(PISNT=_Pis.Pisnt(CST=cst))
if cst in ("01", "02"):
return _Pis(
PISAliq=_Pis.Pisaliq(CST=cst, vBC=_base(linha), pPIS=_pct(linha), vPIS=_val(linha))
)
return _Pis(PISOutr=_Pis.Pisoutr(CST="99", vBC=_base(linha), pPIS=_pct(linha), vPIS=_val(linha)))
def _build_cofins(linha: TributoLinha | None) -> _Cofins:
if linha is None or linha.cst is None:
return _Cofins(COFINSNT=_Cofins.Cofinsnt(CST="08"))
cst = linha.cst
if cst in _PIS_COFINS_NT_CSTS:
return _Cofins(COFINSNT=_Cofins.Cofinsnt(CST=cst))
if cst in ("01", "02"):
return _Cofins(
COFINSAliq=_Cofins.Cofinsaliq(
CST=cst, vBC=_base(linha), pCOFINS=_pct(linha), vCOFINS=_val(linha)
)
)
return _Cofins(
COFINSOutr=_Cofins.Cofinsoutr(
CST="99", vBC=_base(linha), pCOFINS=_pct(linha), vCOFINS=_val(linha)
)
)
def _linha_por_dominio(fiscal_result: FiscalResult, dominio: str) -> TributoLinha | None:
for linha in fiscal_result.tributos:
if linha.tax_domain == dominio:
return linha
return None
# M4 (auditoria Fable, 2026-07-16): SEM fonte real -- ver o docstring de
# `_build_ibscbs`. Módulo-level (não literais inline) de propósito, para o
# nome do valor já denunciar no call-site que é fabricado.
_PLACEHOLDER_CST_SEM_FONTE_REAL = "000"
_PLACEHOLDER_CCLASSTRIB_SEM_FONTE_REAL = "000001"
def _build_ibscbs(fiscal_result: FiscalResult):
"""Grupo UB (IBS/CBS, RTC v1.40) -- só presente quando o `FiscalResult`
tiver linhas `ibs`/`cbs` (CRT 1 em 2026 nunca tem -- R1 do motor exige
isso a partir de 04/01/2027, ver spec). Best-effort ESTRUTURAL: os
valores de `vBC`/`pIBSUF`/`vIBSUF`/`pCBS`/`vCBS` vêm das linhas
resolvidas (REAIS -- `aliquota`/`valor`/`base_calc` do `TributoLinha`,
configurados pelo tenant na regra), mas `CST`/`cClassTrib` abaixo são
FABRICADOS ("000"/"000001") -- não existe fonte real para nenhum dos
dois hoje: `DOMAIN_FIELDS['ibs'|'cbs']` nem aceita `cst`/
`codigo_beneficio` no cadastro da regra, só `aliquota`/
`base_calc_percent` (ver `fiscal.domains`). O grupo RTC completo
(gIBSUF/gIBSMun/gCBS com suas dezenas de sub-campos) também ainda não
tem um mapeamento tão maduro quanto o ICMS legado.
M4 (auditoria Fable, 2026-07-16): esta função é PROVADAMENTE
inalcançável a partir da emissão real -- `fiscal.emissao.
emitir_documento` barra com 409 `fiscal_config_missing` ANTES de
montar o XML sempre que algum item resolve uma linha ibs/cbs
(exatamente o caso em que este `if` abaixo deixaria de devolver
`None`). Só existe aqui para o builder PURO continuar com o suporte
ESTRUTURAL testável isoladamente (`test_grupo_ibscbs_presente_com_
linhas_ibs_cbs`) -- endurecer (CST/cClassTrib reais) junto com o R1 do
motor, quando o catálogo de domínios ganhar esses campos."""
ibs_linha = _linha_por_dominio(fiscal_result, "ibs")
cbs_linha = _linha_por_dominio(fiscal_result, "cbs")
if ibs_linha is None and cbs_linha is None:
return None
from nfelib.nfe.bindings.v4_0.dfe_tipos_basicos_v1_00 import Tcibs, TtribNfe
base = ibs_linha or cbs_linha
g_ibs_uf = (
Tcibs.GIbsuf(pIBSUF=_pct(ibs_linha), vIBSUF=_val(ibs_linha)) if ibs_linha else None
)
g_cbs = Tcibs.GCbs(pCBS=_pct(cbs_linha), vCBS=_val(cbs_linha)) if cbs_linha else None
gibscbs = Tcibs(vBC=_base(base), gIBSUF=g_ibs_uf, gCBS=g_cbs)
# M4: nomeados _PLACEHOLDER_* (não `CST`/`cClassTrib` soltos) de
# propósito -- deixa explícito, no próprio call-site, que estes DOIS
# valores não vêm de lugar nenhum real (ver o docstring acima).
return TtribNfe(
CST=_PLACEHOLDER_CST_SEM_FONTE_REAL,
cClassTrib=_PLACEHOLDER_CCLASSTRIB_SEM_FONTE_REAL,
gIBSCBS=gibscbs,
)
def _build_det(item: ItemData, n_item: int) -> _Det:
fr = item.fiscal_result
icms_linha = _linha_por_dominio(fr, "icms")
icmsst_linha = _linha_por_dominio(fr, "icmsst")
pis_linha = _linha_por_dominio(fr, "pis")
cofins_linha = _linha_por_dominio(fr, "cofins")
v_prod = (item.quantidade * item.valor_unitario).quantize(_CENT, rounding=ROUND_HALF_UP)
prod = _Det.Prod(
cProd=item.codigo,
cEAN=item.gtin or _SEM_GTIN,
xProd=item.descricao,
NCM=item.ncm,
CEST=item.cest,
CFOP=item.cfop,
uCom=item.unidade_comercial,
qCom=str(item.quantidade),
vUnCom=str(item.valor_unitario),
vProd=str(v_prod),
cEANTrib=item.gtin or _SEM_GTIN,
uTrib=item.unidade_tributavel,
qTrib=str(item.quantidade),
vUnTrib=str(item.valor_unitario),
indTot="1",
)
imposto = _Det.Imposto(
ICMS=_build_icms(fr, icms_linha, icmsst_linha),
PIS=_build_pis(pis_linha),
COFINS=_build_cofins(cofins_linha),
IBSCBS=_build_ibscbs(fr),
)
return _Det(prod=prod, imposto=imposto, nItem=str(n_item))
def soma_itens_quantizados(itens: list[ItemData]) -> Decimal:
"""Fonte ÚNICA do somatório de produtos (I1, review Opus 2026-07-16):
soma dos `vProd` POR ITEM já quantizados (2 casas, ROUND_HALF_UP) --
exatamente o valor que aparece em cada `<det><prod><vProd>` e, portanto,
o único somatório que fecha com `vProd`/`vNF` do `<ICMSTot>`. O `vPag`
do orquestrador (`emissao.py`) TEM que derivar daqui também: qualquer
outro caminho (ex.: somar `qtd*preço` cru e quantizar só o agregado)
diverge em 1 centavo quando um item cai em fração de centavo, e a SEFAZ
rejeita com "Valor do Pagamento difere do total"."""
total = sum(
(item.quantidade * item.valor_unitario).quantize(_CENT, rounding=ROUND_HALF_UP)
for item in itens
)
return Decimal(total).quantize(_CENT, rounding=ROUND_HALF_UP)
def _sum_valor(itens: list[ItemData], dominio: str) -> Decimal:
total = Decimal("0.00")
for item in itens:
linha = _linha_por_dominio(item.fiscal_result, dominio)
if linha is not None:
total += linha.valor
return total.quantize(_CENT, rounding=ROUND_HALF_UP)
def _sum_base(itens: list[ItemData], dominio: str) -> Decimal:
total = Decimal("0.00")
for item in itens:
linha = _linha_por_dominio(item.fiscal_result, dominio)
if linha is not None:
total += linha.base_calc
return total.quantize(_CENT, rounding=ROUND_HALF_UP)
def _build_total(itens: list[ItemData]) -> _InfNfe.Total:
v_prod = soma_itens_quantizados(itens)
v_icms = _sum_valor(itens, "icms")
v_bc = _sum_base(itens, "icms")
v_st = _sum_valor(itens, "icmsst")
v_bcst = _sum_base(itens, "icmsst")
v_pis = _sum_valor(itens, "pis")
v_cofins = _sum_valor(itens, "cofins")
zero = "0.00"
icms_tot = _InfNfe.Total.Icmstot(
vBC=str(v_bc), vICMS=str(v_icms), vICMSDeson=zero, vFCP=zero,
vBCST=str(v_bcst), vST=str(v_st), vFCPST=zero, vFCPSTRet=zero,
vProd=str(v_prod), vFrete=zero, vSeg=zero, vDesc=zero,
vII=zero, vIPI=zero, vIPIDevol=zero, vPIS=str(v_pis), vCOFINS=str(v_cofins),
vOutro=zero, vNF=str(v_prod),
)
return _InfNfe.Total(ICMSTot=icms_tot)
def _build_ide(dados: DadosEmissao) -> _InfNfe.Ide:
id_dest = "1" if dados.uf_destino_tipo == "interna" else "2"
return _InfNfe.Ide(
cUF=dados.chave_acesso[0:2],
cNF=dados.cnf,
natOp=dados.nat_op,
mod="55",
serie=str(dados.serie),
nNF=str(dados.numero),
dhEmi=dados.dh_emi,
tpNF="1",
idDest=id_dest,
cMunFG=dados.emitente.address_city_ibge_code,
tpImp="1",
tpEmis=dados.tp_emis,
cDV=dados.chave_acesso[-1],
tpAmb="2" if dados.ambiente == "homologacao" else "1",
finNFe=dados.fin_nfe,
indFinal=dados.ind_final,
indPres=dados.ind_pres,
procEmi="0",
verProc=dados.ver_proc,
)
def _build_emit(emitente: EmitenteData) -> _InfNfe.Emit:
ender = TenderEmi(
xLgr=emitente.address_street,
nro=emitente.address_number,
xCpl=emitente.address_complement,
xBairro=emitente.address_district,
cMun=emitente.address_city_ibge_code,
xMun=emitente.address_city,
UF=emitente.address_state,
CEP=emitente.address_zip,
cPais="1058",
xPais="Brasil",
fone=emitente.fone,
)
return _InfNfe.Emit(
CNPJ=emitente.cnpj,
xNome=emitente.razao_social,
xFant=emitente.nome_fantasia,
enderEmit=ender,
IE=emitente.ie,
CRT=emitente.crt,
)
def _build_dest(dados: DadosEmissao) -> _InfNfe.Dest | None:
dest = dados.destinatario
if dest is None:
return None
x_nome = TEXTO_HOMOLOGACAO if dados.ambiente == "homologacao" else dest.nome
ender = None
if dest.address_street is not None:
ender = Tendereco(
xLgr=dest.address_street,
nro=dest.address_number,
xCpl=dest.address_complement,
xBairro=dest.address_district,
cMun=dest.address_city_ibge_code,
xMun=dest.address_city,
UF=dest.address_state,
CEP=dest.address_zip,
cPais="1058",
xPais="Brasil",
)
return _InfNfe.Dest(
CNPJ=dest.cnpj,
CPF=dest.cpf,
xNome=x_nome,
enderDest=ender,
indIEDest=dest.indicador_ie,
IE=dest.ie,
email=dest.email,
)
def _build_transp() -> _InfNfe.Transp:
# 1b.1: venda de balcão/PDV -- sem transportador/volume rastreado ainda
# (Sale/SaleItem não carregam esses dados). modFrete=9 "Sem Ocorrência
# de Transporte" é o valor correto para essa realidade, não um chute.
return _InfNfe.Transp(modFrete="9")
def _build_pag(pagamento: PagamentoData) -> _InfNfe.Pag:
det_pag = _InfNfe.Pag.DetPag(
indPag=pagamento.indpag,
tPag=pagamento.tpag,
vPag=str(pagamento.valor.quantize(_CENT, rounding=ROUND_HALF_UP)),
)
return _InfNfe.Pag(detPag=[det_pag])
def build_nfe(dados: DadosEmissao) -> Nfe:
"""Monta o objeto `Nfe` (nfelib) completo a partir de `DadosEmissao` --
PURA: nenhuma chamada de rede/banco, nenhuma decisão de negócio (tudo
que precisava de contexto -- CFOP, CST/CSOSN, ambiente, chave -- já
veio resolvido no input pelo orquestrador). O `Id` do `infNFe` é
`"NFe" + chave_acesso` (44 dígitos), exatamente o formato que
`erpbrasil.assinatura` referencia na assinatura (Task 6)."""
ide = _build_ide(dados)
emit = _build_emit(dados.emitente)
dest = _build_dest(dados)
dets = [_build_det(item, n) for n, item in enumerate(dados.itens, start=1)]
total = _build_total(dados.itens)
transp = _build_transp()
pag = _build_pag(dados.pagamento)
inf_nfe = _InfNfe(
ide=ide,
emit=emit,
dest=dest,
det=dets,
total=total,
transp=transp,
pag=pag,
versao="4.00",
Id="NFe" + dados.chave_acesso,
)
return Nfe(infNFe=inf_nfe)