feat: emission v1 + idempotency (Task 5)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c903a9ce0e
commit
3aae8b67ef
@@ -215,3 +215,42 @@ class FiscalDocument(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
|
||||
rejeicao_motivo: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
protocolo: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
autorizada_em: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class FiscalIdempotencyKey(Base, UUIDPKMixin, TimestampMixin):
|
||||
"""Task 5: backs the `Idempotency-Key` contract of `POST /v1/emissoes`
|
||||
(design spec decision #6) -- a SEPARATE table rather than a column on
|
||||
`FiscalDocument` (Task 3's table, already shipped/migrated) so this
|
||||
Task never touches that migration. `UNIQUE (product_id,
|
||||
idempotency_key)` is the SAME-transaction outbox partner of `emission.
|
||||
service.emitir_documento`'s number-allocation + document-INSERT commit
|
||||
(`documents.service.allocate_fiscal_number`'s docstring): this row is
|
||||
added to the SAME session, in the SAME commit, as the `FiscalDocument`
|
||||
it points to via `document_id` -- so a rollback (e.g. the idempotency
|
||||
race below, or a signature failure) undoes the allocated number AND the
|
||||
document row AND this key together, never just some of the three.
|
||||
|
||||
The two-layer idempotency pattern this table exists for: (1) a cheap
|
||||
pre-check SELECT before doing any real work (the fast path for a
|
||||
genuine retry); (2) this UNIQUE constraint as the source of truth for
|
||||
the RACE -- two concurrent requests carrying the SAME `Idempotency-Key`
|
||||
can both pass the pre-check (`None`) before either commits; the FIRST
|
||||
to commit wins, the SECOND's commit raises `IntegrityError` here, which
|
||||
`emission.service` catches and translates into a RE-READ of the
|
||||
winner's row (converging both callers on the SAME `FiscalDocument`,
|
||||
never emitting a duplicate NF-e for one logical request).
|
||||
|
||||
No soft-delete mixin: an idempotency key's history is permanent by
|
||||
design -- there is no "un-claim this key" operation."""
|
||||
|
||||
__tablename__ = "fiscal_idempotency_keys"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"product_id", "idempotency_key",
|
||||
name="uq_fiscal_idempotency_key_product_key",
|
||||
),
|
||||
)
|
||||
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("products.id"), nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
document_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("fiscal_documents.id"), nullable=False)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fiscal_svc.core.db import get_session
|
||||
from fiscal_svc.emission import service
|
||||
from fiscal_svc.emission.schemas import EmissaoRequest, FiscalDocumentRead
|
||||
from fiscal_svc.shared.errors import conflict
|
||||
from fiscal_svc.tenancy.deps import require_product
|
||||
from fiscal_svc.tenancy.models import Product
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["emissao"])
|
||||
|
||||
|
||||
@router.post("/emissoes", response_model=FiscalDocumentRead)
|
||||
async def emitir_documento_endpoint(
|
||||
payload: EmissaoRequest,
|
||||
response: Response,
|
||||
idempotency_key: str = Header(..., alias="Idempotency-Key", max_length=255, min_length=1),
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FiscalDocumentRead:
|
||||
"""`Idempotency-Key` OBRIGATÓRIO (design spec decisão #6) -- ausente
|
||||
vira 422 na borda do FastAPI (`Header(...)`, sem default), antes de
|
||||
qualquer lógica rodar. Repetida com a MESMA `product_id` -> 200 com o
|
||||
documento já existente (`created=False`); nova -> 201 (`created=True`).
|
||||
Ver `emission.service.emitir_documento`'s docstring para o padrão de
|
||||
duas camadas que garante isto mesmo sob duas requisições concorrentes
|
||||
com a mesma chave."""
|
||||
try:
|
||||
document, created = await service.emitir_documento(session, product, payload, idempotency_key)
|
||||
except service.FiscalConfigMissingError as exc:
|
||||
raise conflict("fiscal_config_missing", str(exc)) from exc
|
||||
except service.FiscalDocumentConflictError as exc:
|
||||
raise conflict("fiscal_document_conflict", str(exc)) from exc
|
||||
response.status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK
|
||||
return FiscalDocumentRead.model_validate(document)
|
||||
|
||||
|
||||
@router.get("/documentos/{document_id}", response_model=FiscalDocumentRead)
|
||||
async def get_fiscal_document_endpoint(
|
||||
document_id: uuid.UUID,
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FiscalDocumentRead:
|
||||
document = await service.get_fiscal_document(session, product.id, document_id)
|
||||
if document is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Documento fiscal não encontrado"
|
||||
)
|
||||
return FiscalDocumentRead.model_validate(document)
|
||||
|
||||
|
||||
@router.get("/documentos/{document_id}/xml")
|
||||
async def get_fiscal_document_xml_endpoint(
|
||||
document_id: uuid.UUID,
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Response:
|
||||
document = await service.get_fiscal_document(session, product.id, document_id)
|
||||
if document is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Documento fiscal não encontrado"
|
||||
)
|
||||
return Response(content=document.xml_assinado, media_type="application/xml")
|
||||
|
||||
|
||||
@router.get("/documentos", response_model=list[FiscalDocumentRead])
|
||||
async def list_fiscal_documents_endpoint(
|
||||
tenant_ref: str | None = Query(default=None, max_length=64),
|
||||
branch_ref: str | None = Query(default=None, max_length=64),
|
||||
status_filter: str | None = Query(default=None, alias="status", max_length=20),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[FiscalDocumentRead]:
|
||||
documents = await service.list_fiscal_documents(
|
||||
session, product.id,
|
||||
tenant_ref=tenant_ref, branch_ref=branch_ref, status_=status_filter,
|
||||
limit=limit, offset=offset,
|
||||
)
|
||||
return [FiscalDocumentRead.model_validate(d) for d in documents]
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Task 5: `EmissaoRequest` -- the pydantic mirror of `sowai_fiscal.
|
||||
xml_builder.DadosEmissao` (+ `sowai_fiscal.resolver.FiscalResult`/
|
||||
`TributoLinha`, which the lib itself already defines as pydantic
|
||||
`BaseModel`s -- these two are NOT re-mirrored here as separate payload
|
||||
classes, `emission.service` constructs them straight via `FiscalResult(**
|
||||
...)`/`TributoLinha(**...)`), adapted per the porte table and design spec:
|
||||
|
||||
* `tenant_ref`/`branch_ref` (opaque strings, decision #4) replace what in
|
||||
the auto was implicit (`sale.branch_id`).
|
||||
* `document_model`/`serie` select WHICH `FiscalSeries` to allocate from
|
||||
(Task 4) -- `chave_acesso`/`numero`/`cnf`/`dh_emi` are DROPPED from this
|
||||
payload entirely (unlike the lib's own `DadosEmissao`, which expects
|
||||
them pre-computed): this SERVICE computes all four itself (`cNF do
|
||||
serviço, persistido`, plan Task 5) -- a caller can never inject its own
|
||||
chave/número, closing the exact class of bug the auto's own emission
|
||||
code had to defend against for `branch_id` (C1, "auditoria Fable
|
||||
2026-07-16", `auto/backend/app/modules/fiscal/emissao.py`'s module
|
||||
docstring).
|
||||
* `ver_proc` is REQUIRED (no default) -- design spec's "EMENDA F1": the
|
||||
lib's own `DadosEmissao.ver_proc` defaults to `"sowai-auto/1b.1"`, a
|
||||
default that made sense when this code lived IN the auto and is
|
||||
actively WRONG for every other product calling this shared service. A
|
||||
plain (no-default) pydantic field is already "required, 422 if absent"
|
||||
-- no extra validator needed.
|
||||
* `fiscal_result` per item is the motor de regras' OUTPUT (design spec
|
||||
decision #3: "O serviço NÃO resolve imposto (FiscalResult vem no
|
||||
payload)") -- this service treats it as opaque data to embed in the
|
||||
XML, never recomputes it."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class TributoLinhaPayload(BaseModel):
|
||||
tax_domain: str
|
||||
cst: str | None = None
|
||||
csosn: str | None = None
|
||||
base_calc: Decimal
|
||||
base_calc_percent: Decimal
|
||||
aliquota: Decimal | None = None
|
||||
valor: Decimal
|
||||
mva: Decimal | None = None
|
||||
aliquota_st: Decimal | None = None
|
||||
fcp_percent: Decimal | None = None
|
||||
codigo_beneficio: str | None = None
|
||||
rule_id: uuid.UUID
|
||||
|
||||
|
||||
class FiscalResultPayload(BaseModel):
|
||||
cfop: str
|
||||
cst: str | None = None
|
||||
csosn: str | None = None
|
||||
origem: str | None = None
|
||||
consumidor_final: bool
|
||||
indicador_ie: str
|
||||
tributos: list[TributoLinhaPayload] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmitenteDataPayload(BaseModel):
|
||||
cnpj: str = Field(min_length=14, max_length=14)
|
||||
razao_social: str
|
||||
nome_fantasia: str | None = None
|
||||
ie: str
|
||||
crt: str = Field(min_length=1, max_length=1)
|
||||
address_street: str
|
||||
address_number: str
|
||||
address_complement: str | None = None
|
||||
address_district: str
|
||||
address_city: str
|
||||
address_state: str = Field(min_length=2, max_length=2)
|
||||
address_zip: str
|
||||
address_city_ibge_code: str
|
||||
fone: str | None = None
|
||||
|
||||
|
||||
class DestinatarioDataPayload(BaseModel):
|
||||
"""`None` no `EmissaoRequest.destinatario` == consumidor final não
|
||||
identificado -- espelha `sowai_fiscal.xml_builder.DestinatarioData`."""
|
||||
|
||||
nome: str
|
||||
cnpj: str | None = None
|
||||
cpf: str | None = None
|
||||
indicador_ie: str
|
||||
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
|
||||
|
||||
|
||||
class ItemDataPayload(BaseModel):
|
||||
codigo: str
|
||||
descricao: str
|
||||
ncm: str
|
||||
cfop: str
|
||||
unidade_comercial: str
|
||||
unidade_tributavel: str
|
||||
quantidade: Decimal = Field(gt=0)
|
||||
valor_unitario: Decimal = Field(gt=0)
|
||||
fiscal_result: FiscalResultPayload
|
||||
gtin: str | None = None
|
||||
cest: str | None = None
|
||||
peso_liquido_kg: Decimal | None = None
|
||||
peso_bruto_kg: Decimal | None = None
|
||||
|
||||
|
||||
class PagamentoDataPayload(BaseModel):
|
||||
tpag: str
|
||||
valor: Decimal = Field(gt=0)
|
||||
indpag: str = "0"
|
||||
|
||||
|
||||
class EmissaoRequest(BaseModel):
|
||||
tenant_ref: str = Field(max_length=64)
|
||||
branch_ref: str = Field(max_length=64)
|
||||
# Only "55" for now -- see `series.schemas.FiscalSeriesCreate`'s
|
||||
# docstring for why (`xml_builder.build_nfe` hardcodes `mod="55"`).
|
||||
document_model: Literal["55"] = "55"
|
||||
serie: int = Field(ge=0)
|
||||
emitente: EmitenteDataPayload
|
||||
itens: list[ItemDataPayload] = Field(min_length=1)
|
||||
pagamento: PagamentoDataPayload
|
||||
ambiente: Literal["homologacao", "producao"]
|
||||
uf_destino_tipo: Literal["interna", "interestadual"]
|
||||
destinatario: DestinatarioDataPayload | None = None
|
||||
nat_op: str = "Venda"
|
||||
tp_emis: str = Field(default="1", min_length=1, max_length=1)
|
||||
ind_final: str = Field(default="1", min_length=1, max_length=1)
|
||||
ind_pres: str = Field(default="1", min_length=1, max_length=1)
|
||||
fin_nfe: str = Field(default="1", min_length=1, max_length=1)
|
||||
# OBRIGATÓRIO -- ver o docstring do módulo (EMENDA F1). Nenhum default:
|
||||
# ausente no payload -> 422 na borda do FastAPI, antes de qualquer
|
||||
# lógica de negócio rodar.
|
||||
ver_proc: str = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class FiscalDocumentRead(BaseModel):
|
||||
"""NUNCA inclui `xml_assinado` -- o XML sai só por `GET /v1/documentos/
|
||||
{id}/xml` (`Response(media_type="application/xml")`), mesmo racional de
|
||||
`FiscalCertificateRead` nunca incluir o binário do certificado. Sem
|
||||
`sale_id`/`service_order_id` (porte table: este serviço não conhece o
|
||||
domínio do produto chamador)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
product_id: uuid.UUID
|
||||
tenant_ref: str
|
||||
branch_ref: str
|
||||
series_id: uuid.UUID
|
||||
document_model: str
|
||||
serie: int
|
||||
numero: int
|
||||
chave_acesso: str
|
||||
codigo_numerico: str
|
||||
status: str
|
||||
ambiente: str
|
||||
rejeicao_codigo: str | None
|
||||
rejeicao_motivo: str | None
|
||||
protocolo: str | None
|
||||
autorizada_em: datetime | None
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,430 @@
|
||||
"""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())
|
||||
@@ -2,12 +2,14 @@ from fastapi import FastAPI
|
||||
|
||||
from fiscal_svc.certificates.router import router as certificates_router
|
||||
from fiscal_svc.core.config import settings
|
||||
from fiscal_svc.emission.router import router as emission_router
|
||||
from fiscal_svc.series.router import router as series_router
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
|
||||
app.include_router(certificates_router)
|
||||
app.include_router(series_router)
|
||||
app.include_router(emission_router)
|
||||
|
||||
|
||||
@app.get("/v1/health")
|
||||
|
||||
Reference in New Issue
Block a user