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
@@ -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
|
||||
Reference in New Issue
Block a user