431 lines
17 KiB
Python
431 lines
17 KiB
Python
"""Task 5: emissão de NF-e (paridade 1b.1) -- ported from the auto's
|
|
`app/modules/fiscal/emissao.py`, porte table applied throughout. The
|
|
biggest structural change (design spec decision #3): this service receives
|
|
a COMPLETE `EmissaoRequest` -- it never collects Sale/Branch/Person/Part
|
|
rows, never calls `resolve_fiscal`, and validates only STRUCTURAL
|
|
completeness (a certificate exists for the branch_ref, a series exists for
|
|
the (document_model, serie)) rather than an entire domain's worth of
|
|
cadastro fields. The outbox invariant survives verbatim: número allocation
|
|
(`documents.service.allocate_fiscal_number`, no-commit contract) and the
|
|
`FiscalDocument` + `FiscalIdempotencyKey` INSERTs happen in the SAME
|
|
transaction, ONE commit -- any failure (including a sabotaged signature)
|
|
rolls back all three together, never burning a number with nothing to show
|
|
for it.
|
|
|
|
ORDER (mirrors the auto's own emissao.py, same reason: `gerar_cnf(numero)`
|
|
needs the number ALREADY allocated -- cNF != nNF is NT2019.001):
|
|
1. idempotency pre-check (`Idempotency-Key` -> existing document, if any).
|
|
2. completeness: live certificate for branch_ref, existing series for
|
|
(document_model, serie) -- BOTH checked BEFORE touching
|
|
`allocate_fiscal_number`, so a missing config never wastes a número.
|
|
3. `allocate_fiscal_number` (lock, no commit) -> `gerar_cnf` -> chave.
|
|
4. `build_nfe` (pure, `sowai_fiscal`) -> serialize -> assina.
|
|
5. `FiscalDocument(ASSINADO)` + `FiscalIdempotencyKey` added to the SAME
|
|
session -> ONE commit. A UNIQUE-violation on the idempotency key here
|
|
(the race: two concurrent requests, same key, both passed step 1
|
|
before either committed) rolls back and RE-READS the winner's row --
|
|
see `documents.models.FiscalIdempotencyKey`'s docstring for the full
|
|
two-layer pattern this implements."""
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
|
from cryptography.x509 import Certificate
|
|
from erpbrasil.assinatura.assinatura import Assinatura
|
|
from lxml import etree
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from xsdata.formats.dataclass.serializers import XmlSerializer
|
|
from xsdata.formats.dataclass.serializers.config import SerializerConfig
|
|
|
|
from fiscal_svc.certificates import crypto as certificate_lib
|
|
from fiscal_svc.documents.models import (
|
|
FiscalCertificate,
|
|
FiscalDocument,
|
|
FiscalDocumentStatus,
|
|
FiscalIdempotencyKey,
|
|
FiscalSeries,
|
|
)
|
|
from fiscal_svc.documents.service import FiscalSeriesNotFoundError, allocate_fiscal_number
|
|
from fiscal_svc.emission.schemas import (
|
|
DestinatarioDataPayload,
|
|
EmissaoRequest,
|
|
EmitenteDataPayload,
|
|
FiscalResultPayload,
|
|
ItemDataPayload,
|
|
PagamentoDataPayload,
|
|
TributoLinhaPayload,
|
|
)
|
|
from fiscal_svc.tenancy.models import Product
|
|
from sowai_fiscal.chave_acesso import gerar_cnf, montar_chave_acesso
|
|
from sowai_fiscal.resolver import FiscalResult, TributoLinha
|
|
from sowai_fiscal.xml_builder import (
|
|
DadosEmissao,
|
|
DestinatarioData,
|
|
EmitenteData,
|
|
ItemData,
|
|
PagamentoData,
|
|
build_nfe,
|
|
)
|
|
|
|
_NFE_NAMESPACE = "http://www.portalfiscal.inf.br/nfe"
|
|
|
|
# M4 do auto (review Opus, 2026-07-16), preservado: dhEmi e o AAMM da chave
|
|
# em horário de Brasília, NÃO UTC -- na virada de mês o AAMM em UTC cairia
|
|
# no período de apuração errado e a chave divergiria do dhEmi. O Brasil não
|
|
# observa mais horário de verão (desde 2019), então o offset é sempre
|
|
# -03:00 para America/Sao_Paulo.
|
|
_TZ_EMISSAO = ZoneInfo("America/Sao_Paulo")
|
|
|
|
|
|
class FiscalConfigMissingError(Exception):
|
|
"""Fail-closed: campos estruturais ausentes para EMITIR (certificado/
|
|
série) -- router mapeia para 409 `fiscal_config_missing`, nomeando os
|
|
campos. Ao contrário do auto (que também validava dezenas de campos de
|
|
cadastro de Branch/Person/Part), este serviço só valida o que É DELE:
|
|
o `EmissaoRequest` inteiro já passou pela borda pydantic (campos
|
|
obrigatórios/tipos), e o `FiscalResult` por item já vem RESOLVIDO
|
|
(design spec decisão #3) -- não há cadastro para revalidar aqui."""
|
|
|
|
def __init__(self, missing: list[str]):
|
|
self.missing = missing
|
|
super().__init__("Configuração fiscal ausente para emissão: " + ", ".join(missing))
|
|
|
|
|
|
class FiscalDocumentConflictError(Exception):
|
|
"""`chave_acesso` é UNIQUE GLOBAL (`uq_fiscal_documents_chave_acesso`,
|
|
Task 3) -- uma colisão no INSERT final (cNF repetido por acaso para o
|
|
mesmo nNF, ou qualquer outra causa) estouraria `IntegrityError` direto
|
|
do driver; traduzido aqui em 409 `fiscal_document_conflict`. O
|
|
`rollback()` que acompanha desfaz a alocação do número junto (mesmo
|
|
outbox de qualquer outra falha antes do commit) -- a colisão nunca
|
|
queima um número."""
|
|
|
|
def __init__(self, chave_acesso: str):
|
|
self.chave_acesso = chave_acesso
|
|
super().__init__(
|
|
f"Colisão de chave de acesso ({chave_acesso}) ao gravar o documento fiscal — tente novamente"
|
|
)
|
|
|
|
|
|
def _is_chave_acesso_constraint_violation(exc: IntegrityError) -> bool:
|
|
detail = str(getattr(exc.orig, "args", [""])[0]) if exc.orig else str(exc)
|
|
return "uq_fiscal_documents_chave_acesso" in detail.lower()
|
|
|
|
|
|
def _is_idempotency_key_constraint_violation(exc: IntegrityError) -> bool:
|
|
detail = str(getattr(exc.orig, "args", [""])[0]) if exc.orig else str(exc)
|
|
return "uq_fiscal_idempotency_key_product_key" in detail.lower()
|
|
|
|
|
|
class _SignerCertificado:
|
|
"""Shim mínimo para `erpbrasil.assinatura.Assinatura`, que espera um
|
|
objeto `certificado` com atributos `.key`/`._cert`/`._chave`/`._senha`
|
|
(a forma de `erpbrasil.assinatura.certificado.Certificado`). Ported
|
|
verbatim from the auto's `fiscal.emissao._SignerCertificado`."""
|
|
|
|
def __init__(self, private_key: RSAPrivateKey, cert: Certificate):
|
|
self.key = private_key
|
|
self.cert = cert
|
|
self._cert = cert.public_bytes(encoding=serialization.Encoding.PEM)
|
|
self._chave = private_key.private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
)
|
|
self._senha = b""
|
|
|
|
|
|
def _serialize_nfe(nfe) -> str:
|
|
config = SerializerConfig(xml_declaration=False, indent=None)
|
|
return XmlSerializer(config=config).render(nfe, ns_map={None: _NFE_NAMESPACE})
|
|
|
|
|
|
def sign_nfe_xml(xml_str: str, chave_acesso: str, private_key: RSAPrivateKey, cert: Certificate) -> str:
|
|
"""Assina o XML (enveloped, `erpbrasil.assinatura`/xmlsec) referenciando
|
|
`infNFe` pelo seu `Id` (`"NFe" + chave_acesso`). Função de módulo (não
|
|
inline em `emitir_documento`) DE PROPÓSITO -- é o ponto exato que o
|
|
teste-prova do outbox (`tests/emission/test_emissao.py`) monkeypatcha
|
|
para forçar uma falha DEPOIS da alocação do número e ANTES do commit."""
|
|
assinatura = Assinatura(_SignerCertificado(private_key, cert))
|
|
root = etree.fromstring(xml_str.encode("utf-8"))
|
|
signed = assinatura.assina_xml2(root, reference="NFe" + chave_acesso)
|
|
return signed.decode("utf-8") if isinstance(signed, bytes) else signed
|
|
|
|
|
|
# --- EmissaoRequest -> dataclasses da lib (sowai_fiscal.xml_builder) --------
|
|
|
|
|
|
def _to_tributo(payload: TributoLinhaPayload) -> TributoLinha:
|
|
return TributoLinha(**payload.model_dump())
|
|
|
|
|
|
def _to_fiscal_result(payload: FiscalResultPayload) -> FiscalResult:
|
|
return FiscalResult(
|
|
cfop=payload.cfop,
|
|
cst=payload.cst,
|
|
csosn=payload.csosn,
|
|
origem=payload.origem,
|
|
consumidor_final=payload.consumidor_final,
|
|
indicador_ie=payload.indicador_ie,
|
|
tributos=[_to_tributo(t) for t in payload.tributos],
|
|
)
|
|
|
|
|
|
def _to_item(payload: ItemDataPayload) -> ItemData:
|
|
return ItemData(
|
|
codigo=payload.codigo,
|
|
descricao=payload.descricao,
|
|
ncm=payload.ncm,
|
|
cfop=payload.cfop,
|
|
unidade_comercial=payload.unidade_comercial,
|
|
unidade_tributavel=payload.unidade_tributavel,
|
|
quantidade=payload.quantidade,
|
|
valor_unitario=payload.valor_unitario,
|
|
fiscal_result=_to_fiscal_result(payload.fiscal_result),
|
|
gtin=payload.gtin,
|
|
cest=payload.cest,
|
|
peso_liquido_kg=payload.peso_liquido_kg,
|
|
peso_bruto_kg=payload.peso_bruto_kg,
|
|
)
|
|
|
|
|
|
def _to_emitente(payload: EmitenteDataPayload) -> EmitenteData:
|
|
return EmitenteData(**payload.model_dump())
|
|
|
|
|
|
def _to_destinatario(payload: DestinatarioDataPayload | None) -> DestinatarioData | None:
|
|
if payload is None:
|
|
return None
|
|
return DestinatarioData(**payload.model_dump())
|
|
|
|
|
|
def _to_pagamento(payload: PagamentoDataPayload) -> PagamentoData:
|
|
return PagamentoData(**payload.model_dump())
|
|
|
|
|
|
# --- lookups internos (duplicados, não importados de certificates.service --
|
|
# mesmo racional "não vale o acoplamento por uma SELECT de poucas linhas"
|
|
# que o auto documenta em `fiscal.emissao._get_live_certificate`) ----------
|
|
|
|
|
|
async def _get_live_certificate(
|
|
session: AsyncSession, product_id: uuid.UUID, branch_ref: str
|
|
) -> FiscalCertificate | None:
|
|
result = await session.execute(
|
|
select(FiscalCertificate).where(
|
|
FiscalCertificate.product_id == product_id,
|
|
FiscalCertificate.branch_ref == branch_ref,
|
|
FiscalCertificate.deleted_at.is_(None),
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def _get_series(
|
|
session: AsyncSession,
|
|
product_id: uuid.UUID,
|
|
tenant_ref: str,
|
|
branch_ref: str,
|
|
document_model: str,
|
|
serie: int,
|
|
) -> FiscalSeries | None:
|
|
result = await session.execute(
|
|
select(FiscalSeries).where(
|
|
FiscalSeries.product_id == product_id,
|
|
FiscalSeries.tenant_ref == tenant_ref,
|
|
FiscalSeries.branch_ref == branch_ref,
|
|
FiscalSeries.document_model == document_model,
|
|
FiscalSeries.serie == serie,
|
|
FiscalSeries.deleted_at.is_(None),
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def _get_document_by_idempotency_key(
|
|
session: AsyncSession, product_id: uuid.UUID, idempotency_key: str
|
|
) -> FiscalDocument | None:
|
|
result = await session.execute(
|
|
select(FiscalDocument)
|
|
.join(FiscalIdempotencyKey, FiscalIdempotencyKey.document_id == FiscalDocument.id)
|
|
.where(
|
|
FiscalIdempotencyKey.product_id == product_id,
|
|
FiscalIdempotencyKey.idempotency_key == idempotency_key,
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def emitir_documento(
|
|
session: AsyncSession,
|
|
product: Product,
|
|
payload: EmissaoRequest,
|
|
idempotency_key: str,
|
|
) -> tuple[FiscalDocument, bool]:
|
|
"""Devolve `(document, created)` -- `created=False` quando `idempotency_
|
|
key` já resolvia para um documento existente (pré-checagem OU corrida
|
|
resolvida via re-leitura), para o router escolher 200 vs 201."""
|
|
# 1. idempotência -- pré-checagem (camada 1 do padrão de duas camadas).
|
|
existing = await _get_document_by_idempotency_key(session, product.id, idempotency_key)
|
|
if existing is not None:
|
|
return existing, False
|
|
|
|
# 2. completude estrutural -- ANTES de alocar número.
|
|
missing: list[str] = []
|
|
|
|
certificate = await _get_live_certificate(session, product.id, payload.branch_ref)
|
|
if certificate is None:
|
|
missing.append("certificado A1 do branch_ref")
|
|
elif certificate.not_valid_after < datetime.now(timezone.utc):
|
|
missing.append("certificado A1 vencido")
|
|
|
|
series = await _get_series(
|
|
session, product.id, payload.tenant_ref, payload.branch_ref, payload.document_model, payload.serie
|
|
)
|
|
if series is None:
|
|
missing.append(f"série fiscal modelo {payload.document_model} série {payload.serie}")
|
|
|
|
if missing:
|
|
raise FiscalConfigMissingError(missing)
|
|
|
|
# 3. aloca o número (lock, sem commit) -> cNF -> chave --------------
|
|
try:
|
|
numero = await allocate_fiscal_number(
|
|
session, product.id, payload.tenant_ref, payload.branch_ref,
|
|
payload.document_model, payload.serie,
|
|
)
|
|
except FiscalSeriesNotFoundError as exc:
|
|
# Corrida rara: a série existia no pré-check acima e sumiu (soft-
|
|
# delete concorrente) antes do SELECT ... FOR UPDATE de allocate.
|
|
# Mesma família de erro que a ausência original -- 409 fiscal_
|
|
# config_missing, não um 500.
|
|
raise FiscalConfigMissingError(
|
|
[f"série fiscal modelo {payload.document_model} série {payload.serie}"]
|
|
) from exc
|
|
|
|
cnf = gerar_cnf(numero)
|
|
# UM `now()` só, em horário de Brasília -- o AAMM da chave e o dhEmi
|
|
# têm que vir do MESMO instante/fuso, senão divergem na virada de mês.
|
|
agora_brasil = datetime.now(_TZ_EMISSAO)
|
|
chave_acesso = montar_chave_acesso(
|
|
uf_ibge=payload.emitente.address_city_ibge_code[:2],
|
|
aamm=agora_brasil.strftime("%y%m"),
|
|
cnpj=payload.emitente.cnpj,
|
|
modelo=payload.document_model,
|
|
serie=payload.serie,
|
|
numero=numero,
|
|
tp_emis=payload.tp_emis,
|
|
cnf=cnf,
|
|
)
|
|
|
|
dados = DadosEmissao(
|
|
emitente=_to_emitente(payload.emitente),
|
|
itens=[_to_item(item) for item in payload.itens],
|
|
pagamento=_to_pagamento(payload.pagamento),
|
|
ambiente=payload.ambiente,
|
|
chave_acesso=chave_acesso,
|
|
numero=numero,
|
|
serie=payload.serie,
|
|
cnf=cnf,
|
|
# `timespec="seconds"` -- o XSD não aceita fração de segundo.
|
|
dh_emi=agora_brasil.isoformat(timespec="seconds"),
|
|
uf_destino_tipo=payload.uf_destino_tipo,
|
|
destinatario=_to_destinatario(payload.destinatario),
|
|
nat_op=payload.nat_op,
|
|
tp_emis=payload.tp_emis,
|
|
ind_final=payload.ind_final,
|
|
ind_pres=payload.ind_pres,
|
|
fin_nfe=payload.fin_nfe,
|
|
ver_proc=payload.ver_proc,
|
|
)
|
|
|
|
# 4. monta + assina ---------------------------------------------------
|
|
nfe = build_nfe(dados)
|
|
xml_str = _serialize_nfe(nfe)
|
|
private_key, cert = certificate_lib.load_private_key_and_cert(certificate)
|
|
xml_assinado = sign_nfe_xml(xml_str, chave_acesso, private_key, cert)
|
|
|
|
# 5. persiste -- MESMO commit da alocação acima + a chave de idempotência
|
|
document = FiscalDocument(
|
|
product_id=product.id,
|
|
tenant_ref=payload.tenant_ref,
|
|
branch_ref=payload.branch_ref,
|
|
series_id=series.id,
|
|
document_model=payload.document_model,
|
|
serie=payload.serie,
|
|
numero=numero,
|
|
chave_acesso=chave_acesso,
|
|
codigo_numerico=cnf,
|
|
status=FiscalDocumentStatus.ASSINADO.value,
|
|
ambiente=payload.ambiente,
|
|
xml_assinado=xml_assinado,
|
|
)
|
|
session.add(document)
|
|
await session.flush() # popula document.id para o FK abaixo
|
|
|
|
idem_row = FiscalIdempotencyKey(
|
|
product_id=product.id, idempotency_key=idempotency_key, document_id=document.id
|
|
)
|
|
session.add(idem_row)
|
|
|
|
try:
|
|
await session.commit()
|
|
except IntegrityError as exc:
|
|
await session.rollback()
|
|
if _is_idempotency_key_constraint_violation(exc):
|
|
# Camada 2 do padrão: um concorrente com a MESMA idempotency_key
|
|
# venceu a corrida entre a pré-checagem (passo 1) e este commit
|
|
# -- re-lê o vencedor e converge, em vez de expor a corrida como
|
|
# erro. O rollback acima já desfez a alocação do número E o
|
|
# INSERT do documento deste caller (outbox intacto).
|
|
winner = await _get_document_by_idempotency_key(session, product.id, idempotency_key)
|
|
if winner is not None:
|
|
return winner, False
|
|
raise
|
|
if _is_chave_acesso_constraint_violation(exc):
|
|
raise FiscalDocumentConflictError(chave_acesso) from exc
|
|
raise
|
|
await session.refresh(document)
|
|
return document, True
|
|
|
|
|
|
async def get_fiscal_document(
|
|
session: AsyncSession, product_id: uuid.UUID, document_id: uuid.UUID
|
|
) -> FiscalDocument | None:
|
|
result = await session.execute(
|
|
select(FiscalDocument).where(
|
|
FiscalDocument.id == document_id,
|
|
FiscalDocument.product_id == product_id,
|
|
FiscalDocument.deleted_at.is_(None),
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def list_fiscal_documents(
|
|
session: AsyncSession,
|
|
product_id: uuid.UUID,
|
|
*,
|
|
tenant_ref: str | None = None,
|
|
branch_ref: str | None = None,
|
|
status_: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> list[FiscalDocument]:
|
|
query = select(FiscalDocument).where(
|
|
FiscalDocument.product_id == product_id, FiscalDocument.deleted_at.is_(None)
|
|
)
|
|
if tenant_ref is not None:
|
|
query = query.where(FiscalDocument.tenant_ref == tenant_ref)
|
|
if branch_ref is not None:
|
|
query = query.where(FiscalDocument.branch_ref == branch_ref)
|
|
if status_ is not None:
|
|
query = query.where(FiscalDocument.status == status_)
|
|
query = query.order_by(FiscalDocument.created_at.desc()).limit(limit).offset(offset)
|
|
result = await session.execute(query)
|
|
return list(result.scalars().all())
|