foxnfe 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- foxnfe/__init__.py +40 -0
- foxnfe/client.py +172 -0
- foxnfe/distribuicao.py +73 -0
- foxnfe/documents.py +58 -0
- foxnfe/events.py +115 -0
- foxnfe/exceptions.py +30 -0
- foxnfe/mcp.py +45 -0
- foxnfe/nfe.py +93 -0
- foxnfe/nfse.py +58 -0
- foxnfe/reference.py +74 -0
- foxnfe/rtc.py +81 -0
- foxnfe/support.py +38 -0
- foxnfe/types.py +110 -0
- foxnfe/webhook.py +71 -0
- foxnfe-1.3.0.dist-info/METADATA +260 -0
- foxnfe-1.3.0.dist-info/RECORD +18 -0
- foxnfe-1.3.0.dist-info/WHEEL +5 -0
- foxnfe-1.3.0.dist-info/top_level.txt +1 -0
foxnfe/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""SDK oficial FOX NF-e para Python."""
|
|
2
|
+
|
|
3
|
+
from .client import Client
|
|
4
|
+
from .exceptions import ApiException, AuthException, FoxNfeException
|
|
5
|
+
from .types import (
|
|
6
|
+
LoginResponse,
|
|
7
|
+
McpTool,
|
|
8
|
+
NfeEmitRequest,
|
|
9
|
+
NfeResource,
|
|
10
|
+
NfseEmitRequest,
|
|
11
|
+
NfseResource,
|
|
12
|
+
)
|
|
13
|
+
from .webhook import Webhook
|
|
14
|
+
from .reference import Reference
|
|
15
|
+
from .documents import Documents
|
|
16
|
+
from .events import NfeEvents
|
|
17
|
+
from .distribuicao import Distribuicao
|
|
18
|
+
from .rtc import Rtc
|
|
19
|
+
from .support import Support
|
|
20
|
+
|
|
21
|
+
__version__ = "1.3.0"
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Client",
|
|
24
|
+
"FoxNfeException",
|
|
25
|
+
"ApiException",
|
|
26
|
+
"AuthException",
|
|
27
|
+
"NfeEmitRequest",
|
|
28
|
+
"NfeResource",
|
|
29
|
+
"NfseEmitRequest",
|
|
30
|
+
"NfseResource",
|
|
31
|
+
"McpTool",
|
|
32
|
+
"LoginResponse",
|
|
33
|
+
"Webhook",
|
|
34
|
+
"Reference",
|
|
35
|
+
"Documents",
|
|
36
|
+
"NfeEvents",
|
|
37
|
+
"Distribuicao",
|
|
38
|
+
"Rtc",
|
|
39
|
+
"Support",
|
|
40
|
+
]
|
foxnfe/client.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Cliente HTTP base do SDK FOX NF-e."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
import requests
|
|
6
|
+
from requests import Response, Session
|
|
7
|
+
|
|
8
|
+
from .exceptions import ApiException, AuthException, FoxNfeException
|
|
9
|
+
from .types import LoginResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Client:
|
|
13
|
+
"""Cliente principal do SDK FOX NF-e."""
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://foxnfe.centralfox.online/api/v1"
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
tenant_slug: str,
|
|
20
|
+
token: Optional[str] = None,
|
|
21
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
22
|
+
timeout: float = 30.0,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.tenant_slug = tenant_slug
|
|
25
|
+
self._token = token
|
|
26
|
+
self._base_url = base_url.rstrip("/")
|
|
27
|
+
self._timeout = timeout
|
|
28
|
+
self._session = Session()
|
|
29
|
+
self._session.headers.update({
|
|
30
|
+
"Accept": "application/json",
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
"X-Tenant-ID": tenant_slug,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
def login(self, email: str, password: str) -> LoginResponse:
|
|
36
|
+
"""Autentica e armazena o token internamente."""
|
|
37
|
+
data = self.post("auth/login", {"email": email, "password": password})
|
|
38
|
+
token = data.get("token")
|
|
39
|
+
if not token:
|
|
40
|
+
raise AuthException("Token não retornado pela API.")
|
|
41
|
+
self._token = token
|
|
42
|
+
return LoginResponse.from_dict(data)
|
|
43
|
+
|
|
44
|
+
def with_token(self, token: str) -> "Client":
|
|
45
|
+
"""Retorna uma nova instância com token definido."""
|
|
46
|
+
client = Client(
|
|
47
|
+
tenant_slug=self.tenant_slug,
|
|
48
|
+
token=token,
|
|
49
|
+
base_url=self._base_url,
|
|
50
|
+
timeout=self._timeout,
|
|
51
|
+
)
|
|
52
|
+
return client
|
|
53
|
+
|
|
54
|
+
def logout(self) -> None:
|
|
55
|
+
"""Invalida o token na API e limpa internamente."""
|
|
56
|
+
self.post("auth/logout")
|
|
57
|
+
self._token = None
|
|
58
|
+
|
|
59
|
+
def me(self) -> dict[str, Any]:
|
|
60
|
+
return self.get("auth/me")
|
|
61
|
+
|
|
62
|
+
# ── Módulos ─────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def nfe(self) -> "Nfe": # type: ignore[name-defined]
|
|
66
|
+
from .nfe import Nfe
|
|
67
|
+
return Nfe(self)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def nfse(self) -> "Nfse": # type: ignore[name-defined]
|
|
71
|
+
from .nfse import Nfse
|
|
72
|
+
return Nfse(self)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def mcp(self) -> "Mcp": # type: ignore[name-defined]
|
|
76
|
+
from .mcp import Mcp
|
|
77
|
+
return Mcp(self)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def webhook(self) -> "Webhook": # type: ignore[name-defined]
|
|
81
|
+
from .webhook import Webhook
|
|
82
|
+
return Webhook(self)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def reference(self) -> "Reference": # type: ignore[name-defined]
|
|
86
|
+
from .reference import Reference
|
|
87
|
+
return Reference(self)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def documents(self) -> "Documents": # type: ignore[name-defined]
|
|
91
|
+
from .documents import Documents
|
|
92
|
+
return Documents(self)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def nfe_events(self) -> "NfeEvents": # type: ignore[name-defined]
|
|
96
|
+
from .events import NfeEvents
|
|
97
|
+
return NfeEvents(self)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def distribuicao(self) -> "Distribuicao": # type: ignore[name-defined]
|
|
101
|
+
from .distribuicao import Distribuicao
|
|
102
|
+
return Distribuicao(self)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def rtc(self) -> "Rtc": # type: ignore[name-defined]
|
|
106
|
+
from .rtc import Rtc
|
|
107
|
+
return Rtc(self)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def support(self) -> "Support": # type: ignore[name-defined]
|
|
111
|
+
from .support import Support
|
|
112
|
+
return Support(self)
|
|
113
|
+
|
|
114
|
+
# ── HTTP helpers ─────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
def get(self, path: str, params: Optional[dict] = None) -> dict[str, Any]:
|
|
117
|
+
return self._request("GET", path, params=params)
|
|
118
|
+
|
|
119
|
+
def post(self, path: str, body: Optional[dict] = None) -> dict[str, Any]:
|
|
120
|
+
return self._request("POST", path, json=body)
|
|
121
|
+
|
|
122
|
+
def put(self, path: str, body: Optional[dict] = None) -> dict[str, Any]:
|
|
123
|
+
return self._request("PUT", path, json=body)
|
|
124
|
+
|
|
125
|
+
def delete(self, path: str) -> dict[str, Any]:
|
|
126
|
+
return self._request("DELETE", path)
|
|
127
|
+
|
|
128
|
+
def upload(self, path: str, field: str, filename: str, content: bytes, fields: Optional[dict] = None) -> dict[str, Any]:
|
|
129
|
+
"""Envio multipart (upload de XML); o Content-Type JSON da sessão é removido."""
|
|
130
|
+
return self._request("POST", path, files={field: (filename, content, "application/xml")}, data=fields or {})
|
|
131
|
+
|
|
132
|
+
def download(self, path: str) -> bytes:
|
|
133
|
+
"""Retorna bytes do conteúdo (XML, PDF)."""
|
|
134
|
+
resp = self._raw_request("GET", path)
|
|
135
|
+
if not resp.ok:
|
|
136
|
+
raise ApiException.from_response(resp.status_code, resp.json() if resp.content else {})
|
|
137
|
+
return resp.content
|
|
138
|
+
|
|
139
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
140
|
+
resp = self._raw_request(method, path, **kwargs)
|
|
141
|
+
try:
|
|
142
|
+
body = resp.json()
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
raise FoxNfeException(f"Resposta JSON inválida: {exc}") from exc
|
|
145
|
+
|
|
146
|
+
if not resp.ok:
|
|
147
|
+
if resp.status_code in (401, 403):
|
|
148
|
+
raise AuthException(body.get("message", "Não autorizado."))
|
|
149
|
+
raise ApiException.from_response(resp.status_code, body)
|
|
150
|
+
|
|
151
|
+
return body # type: ignore[return-value]
|
|
152
|
+
|
|
153
|
+
def _raw_request(self, method: str, path: str, **kwargs: Any) -> Response:
|
|
154
|
+
url = f"{self._base_url}/{path.lstrip('/')}"
|
|
155
|
+
headers: dict[str, Any] = {}
|
|
156
|
+
if self._token:
|
|
157
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
158
|
+
if "files" in kwargs:
|
|
159
|
+
headers["Content-Type"] = None # requests define o boundary multipart
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
return self._session.request(
|
|
163
|
+
method,
|
|
164
|
+
url,
|
|
165
|
+
headers=headers,
|
|
166
|
+
timeout=self._timeout,
|
|
167
|
+
**kwargs,
|
|
168
|
+
)
|
|
169
|
+
except requests.exceptions.Timeout as exc:
|
|
170
|
+
raise FoxNfeException(f"Timeout após {self._timeout}s") from exc
|
|
171
|
+
except requests.exceptions.ConnectionError as exc:
|
|
172
|
+
raise FoxNfeException(f"Erro de conexão: {exc}") from exc
|
foxnfe/distribuicao.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""NF-e recebidas: distribuição DF-e, cursor, captura automática e manifestação do destinatário."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import re
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .client import Client
|
|
9
|
+
|
|
10
|
+
TIPOS_MANIFESTACAO = (210200, 210210, 210220, 210240)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Distribuicao:
|
|
14
|
+
def __init__(self, client: "Client") -> None:
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
def list(self, **params: Any) -> dict[str, Any]:
|
|
18
|
+
return self._client.get("nfe-distribuicao", params=_clean(params))
|
|
19
|
+
|
|
20
|
+
def cursor(self) -> dict[str, Any]:
|
|
21
|
+
return self._client.get("nfe-distribuicao/cursor")
|
|
22
|
+
|
|
23
|
+
def optins(self) -> dict[str, Any]:
|
|
24
|
+
return self._client.get("nfe-distribuicao/captura-automatica")
|
|
25
|
+
|
|
26
|
+
def enable_optin(self, certificate_id: int, ambiente: int) -> dict[str, Any]:
|
|
27
|
+
_positive(certificate_id, "certificate_id")
|
|
28
|
+
return self._client.put("nfe-distribuicao/captura-automatica", {"certificate_id": certificate_id, "ambiente": ambiente})
|
|
29
|
+
|
|
30
|
+
def disable_optin(self, optin_id: int) -> dict[str, Any]:
|
|
31
|
+
_positive(optin_id, "optin_id")
|
|
32
|
+
return self._client.delete(f"nfe-distribuicao/captura-automatica/{optin_id}")
|
|
33
|
+
|
|
34
|
+
def capturar(self, certificate_id: Optional[int] = None, ambiente: Optional[int] = None) -> dict[str, Any]:
|
|
35
|
+
return self._client.post("nfe-distribuicao/capturar", _clean({"certificate_id": certificate_id, "ambiente": ambiente}))
|
|
36
|
+
|
|
37
|
+
def manifestacoes(self, **params: Any) -> dict[str, Any]:
|
|
38
|
+
return self._client.get("nfe-distribuicao/manifestacoes", params=_clean(params))
|
|
39
|
+
|
|
40
|
+
def manifestacao(self, manifestacao_id: int) -> dict[str, Any]:
|
|
41
|
+
_positive(manifestacao_id, "manifestacao_id")
|
|
42
|
+
return self._client.get(f"nfe-distribuicao/manifestacoes/{manifestacao_id}")
|
|
43
|
+
|
|
44
|
+
def solicitar_manifestacao(self, chave_acesso: str, tipo_evento: int, **extra: Any) -> dict[str, Any]:
|
|
45
|
+
if not re.fullmatch(r"[0-9]{6}[0-9A-Z]{12}[0-9]{26}", chave_acesso or ""):
|
|
46
|
+
raise ValueError("chave_acesso inválida (44 posições).")
|
|
47
|
+
if tipo_evento not in TIPOS_MANIFESTACAO:
|
|
48
|
+
raise ValueError("tipo_evento inválido.")
|
|
49
|
+
return self._client.post("nfe-distribuicao/manifestacoes", {"chave_acesso": chave_acesso, "tipo_evento": tipo_evento, **_clean(extra)})
|
|
50
|
+
|
|
51
|
+
def aprovar_manifestacao(self, manifestacao_id: int, payload_hash: str) -> dict[str, Any]:
|
|
52
|
+
"""Aprovação vinculada ao payload_hash devolvido na solicitação."""
|
|
53
|
+
_positive(manifestacao_id, "manifestacao_id")
|
|
54
|
+
if not re.fullmatch(r"[0-9a-f]{64}", payload_hash or ""):
|
|
55
|
+
raise ValueError("payload_hash deve ser sha256 hex minúsculo.")
|
|
56
|
+
return self._client.post(f"nfe-distribuicao/manifestacoes/{manifestacao_id}/aprovar", {"payload_hash": payload_hash})
|
|
57
|
+
|
|
58
|
+
def transmitir_manifestacao(self, manifestacao_id: int) -> dict[str, Any]:
|
|
59
|
+
_positive(manifestacao_id, "manifestacao_id")
|
|
60
|
+
return self._client.post(f"nfe-distribuicao/manifestacoes/{manifestacao_id}/transmitir")
|
|
61
|
+
|
|
62
|
+
def reconciliar_manifestacao(self, manifestacao_id: int) -> dict[str, Any]:
|
|
63
|
+
_positive(manifestacao_id, "manifestacao_id")
|
|
64
|
+
return self._client.post(f"nfe-distribuicao/manifestacoes/{manifestacao_id}/reconciliar")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _clean(d: dict[str, Any]) -> dict[str, Any]:
|
|
68
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _positive(v: int, label: str) -> None:
|
|
72
|
+
if not isinstance(v, int) or v < 1:
|
|
73
|
+
raise ValueError(f"{label} deve ser inteiro positivo.")
|
foxnfe/documents.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Central de Documentos: consulta unificada, detalhe, importação de XML com prévia e exportação ZIP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import re
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .client import Client
|
|
10
|
+
|
|
11
|
+
_UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Documents:
|
|
15
|
+
def __init__(self, client: "Client") -> None:
|
|
16
|
+
self._client = client
|
|
17
|
+
|
|
18
|
+
def list(self, **params: Any) -> dict[str, Any]:
|
|
19
|
+
return self._client.get("documents", params={k: v for k, v in params.items() if v is not None})
|
|
20
|
+
|
|
21
|
+
def show(self, source: str, doc_id: int) -> dict[str, Any]:
|
|
22
|
+
if not re.fullmatch(r"[a-z_]+", source):
|
|
23
|
+
raise ValueError("source inválido.")
|
|
24
|
+
_positive(doc_id, "doc_id")
|
|
25
|
+
return self._client.get(f"documents/{source}/{doc_id}")
|
|
26
|
+
|
|
27
|
+
def import_preview(self, xml: bytes, filename: str = "nota.xml") -> dict[str, Any]:
|
|
28
|
+
"""Valida e vincula SEM persistir; devolve integrity.raw_sha256 para o commit."""
|
|
29
|
+
return self._client.upload("documents/imports/preview", "file", filename, xml)
|
|
30
|
+
|
|
31
|
+
def import_xml(self, xml: bytes, preview_sha256: Optional[str] = None, filename: str = "nota.xml") -> dict[str, Any]:
|
|
32
|
+
fields: dict[str, str] = {}
|
|
33
|
+
if preview_sha256 is not None:
|
|
34
|
+
if not re.fullmatch(r"[0-9a-f]{64}", preview_sha256):
|
|
35
|
+
raise ValueError("preview_sha256 deve ser sha256 hex minúsculo.")
|
|
36
|
+
fields["preview_sha256"] = preview_sha256
|
|
37
|
+
return self._client.upload("documents/imports", "file", filename, xml, fields)
|
|
38
|
+
|
|
39
|
+
def export_request(self, **filters: Any) -> dict[str, Any]:
|
|
40
|
+
return self._client.post("documents/exports", {k: v for k, v in filters.items() if v is not None})
|
|
41
|
+
|
|
42
|
+
def export_status(self, request_id: str) -> dict[str, Any]:
|
|
43
|
+
_uuid(request_id)
|
|
44
|
+
return self._client.get(f"documents/exports/{quote(request_id, safe='')}")
|
|
45
|
+
|
|
46
|
+
def export_download(self, request_id: str) -> bytes:
|
|
47
|
+
_uuid(request_id)
|
|
48
|
+
return self._client.download(f"documents/exports/{quote(request_id, safe='')}/download")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _positive(v: int, label: str) -> None:
|
|
52
|
+
if not isinstance(v, int) or v < 1:
|
|
53
|
+
raise ValueError(f"{label} deve ser inteiro positivo.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _uuid(v: str) -> None:
|
|
57
|
+
if not _UUID.fullmatch(v or ""):
|
|
58
|
+
raise ValueError("request_id deve ser um UUID válido.")
|
foxnfe/events.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Eventos pós-emissão da NF-e: Carta de Correção Eletrônica (assíncrona e durável)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import re
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .client import Client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NfeEvents:
|
|
12
|
+
def __init__(self, client: "Client") -> None:
|
|
13
|
+
self._client = client
|
|
14
|
+
|
|
15
|
+
def cce_list(self, invoice_id: int) -> dict[str, Any]:
|
|
16
|
+
_positive(invoice_id)
|
|
17
|
+
return self._client.get(f"nfe/{invoice_id}/cce")
|
|
18
|
+
|
|
19
|
+
def cce_create(self, invoice_id: int, correcao: str, sequencial: Optional[int] = None) -> dict[str, Any]:
|
|
20
|
+
"""Correção de 15 a 1000 caracteres; sequencial (1..20) opcional — o servidor aloca sob lock."""
|
|
21
|
+
_positive(invoice_id)
|
|
22
|
+
if not 15 <= len(correcao.strip()) <= 1000:
|
|
23
|
+
raise ValueError("correcao deve ter entre 15 e 1000 caracteres.")
|
|
24
|
+
body: dict[str, Any] = {"correcao": correcao}
|
|
25
|
+
if sequencial is not None:
|
|
26
|
+
if not isinstance(sequencial, int) or not 1 <= sequencial <= 20:
|
|
27
|
+
raise ValueError("sequencial deve ser inteiro entre 1 e 20.")
|
|
28
|
+
body["sequencial"] = sequencial
|
|
29
|
+
return self._client.post(f"nfe/{invoice_id}/cce", body)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── 1.3.0 — outros eventos da NF-e própria (L17) ─────────────────────────
|
|
33
|
+
|
|
34
|
+
def ator_interessado_list(self, invoice_id: int) -> dict[str, Any]:
|
|
35
|
+
_positive(invoice_id)
|
|
36
|
+
return self._client.get(f"nfe/{invoice_id}/ator-interessado")
|
|
37
|
+
|
|
38
|
+
def ator_interessado(self, invoice_id: int, documento: str, **extra: Any) -> dict[str, Any]:
|
|
39
|
+
"""Ator interessado (110150). documento = CPF (11) ou CNPJ (14) só dígitos; extra: tp_autor, tp_autorizacao, sequencial."""
|
|
40
|
+
_positive(invoice_id)
|
|
41
|
+
if not re.fullmatch(r"\d{11}|\d{14}", documento):
|
|
42
|
+
raise ValueError("documento deve ter 11 (CPF) ou 14 (CNPJ) dígitos.")
|
|
43
|
+
return self._client.post(f"nfe/{invoice_id}/ator-interessado", {"documento": documento, **extra})
|
|
44
|
+
|
|
45
|
+
def insucesso_entrega_list(self, invoice_id: int) -> dict[str, Any]:
|
|
46
|
+
_positive(invoice_id)
|
|
47
|
+
return self._client.get(f"nfe/{invoice_id}/insucesso-entrega")
|
|
48
|
+
|
|
49
|
+
def insucesso_entrega(self, invoice_id: int, dh_tentativa: str, tp_motivo: int, justificativa: Optional[str] = None, **extra: Any) -> dict[str, Any]:
|
|
50
|
+
"""Insucesso na entrega (110192). tp_motivo 1..4; 4 exige justificativa (15..250). A imagem não é persistida."""
|
|
51
|
+
_positive(invoice_id)
|
|
52
|
+
if tp_motivo not in (1, 2, 3, 4):
|
|
53
|
+
raise ValueError("tp_motivo deve ser 1, 2, 3 ou 4.")
|
|
54
|
+
if tp_motivo == 4 and not 15 <= len((justificativa or "").strip()) <= 250:
|
|
55
|
+
raise ValueError("justificativa (15..250) é obrigatória quando tp_motivo=4.")
|
|
56
|
+
body: dict[str, Any] = {"dh_tentativa": dh_tentativa, "tp_motivo": tp_motivo, **extra}
|
|
57
|
+
if justificativa is not None:
|
|
58
|
+
body["justificativa"] = justificativa
|
|
59
|
+
return self._client.post(f"nfe/{invoice_id}/insucesso-entrega", body)
|
|
60
|
+
|
|
61
|
+
def cancelar_insucesso_entrega(self, invoice_id: int, evento_id: int) -> dict[str, Any]:
|
|
62
|
+
_positive(invoice_id); _positive(evento_id)
|
|
63
|
+
return self._client.post(f"nfe/{invoice_id}/insucesso-entrega/{evento_id}/cancelar")
|
|
64
|
+
|
|
65
|
+
def inutilizacoes(self) -> dict[str, Any]:
|
|
66
|
+
return self._client.get("nfe/inutilizacoes")
|
|
67
|
+
|
|
68
|
+
def inutilizacao(self, inutilizacao_id: int) -> dict[str, Any]:
|
|
69
|
+
_positive(inutilizacao_id)
|
|
70
|
+
return self._client.get(f"nfe/inutilizacoes/{inutilizacao_id}")
|
|
71
|
+
|
|
72
|
+
def inutilizar(self, serie: int, numero_inicial: int, numero_final: int, justificativa: str, **extra: Any) -> dict[str, Any]:
|
|
73
|
+
"""Inutiliza uma faixa de numeração (assíncrono e durável); extra: modelo (55|65), ano."""
|
|
74
|
+
if numero_inicial < 1 or numero_final < numero_inicial:
|
|
75
|
+
raise ValueError("numero_final deve ser >= numero_inicial >= 1.")
|
|
76
|
+
if not 15 <= len(justificativa.strip()) <= 255:
|
|
77
|
+
raise ValueError("justificativa deve ter entre 15 e 255 caracteres.")
|
|
78
|
+
return self._client.post("nfe/inutilizacoes", {"serie": serie, "numero_inicial": numero_inicial, "numero_final": numero_final, "justificativa": justificativa, **extra})
|
|
79
|
+
|
|
80
|
+
# ── 1.3.0 — eventos por contrato oficial (L18) ───────────────────────────
|
|
81
|
+
|
|
82
|
+
def contratos(self) -> dict[str, Any]:
|
|
83
|
+
return self._client.get("nfe/eventos/contratos")
|
|
84
|
+
|
|
85
|
+
def eventos(self, invoice_id: int) -> dict[str, Any]:
|
|
86
|
+
_positive(invoice_id)
|
|
87
|
+
return self._client.get(f"nfe/{invoice_id}/eventos")
|
|
88
|
+
|
|
89
|
+
def registrar_evento(self, invoice_id: int, tipo: str, payload: Optional[dict[str, Any]] = None, sequencial: Optional[int] = None) -> dict[str, Any]:
|
|
90
|
+
_positive(invoice_id); _tipo(tipo)
|
|
91
|
+
body: dict[str, Any] = {"tipo": tipo, **(payload or {})}
|
|
92
|
+
if sequencial is not None:
|
|
93
|
+
body["sequencial"] = sequencial
|
|
94
|
+
return self._client.post(f"nfe/{invoice_id}/eventos", body)
|
|
95
|
+
|
|
96
|
+
def eventos_recebida(self, distribuicao_id: int) -> dict[str, Any]:
|
|
97
|
+
_positive(distribuicao_id)
|
|
98
|
+
return self._client.get(f"nfe/recebidas/{distribuicao_id}/eventos")
|
|
99
|
+
|
|
100
|
+
def registrar_evento_recebida(self, distribuicao_id: int, tipo: str, payload: Optional[dict[str, Any]] = None, sequencial: Optional[int] = None) -> dict[str, Any]:
|
|
101
|
+
_positive(distribuicao_id); _tipo(tipo)
|
|
102
|
+
body: dict[str, Any] = {"tipo": tipo, **(payload or {})}
|
|
103
|
+
if sequencial is not None:
|
|
104
|
+
body["sequencial"] = sequencial
|
|
105
|
+
return self._client.post(f"nfe/recebidas/{distribuicao_id}/eventos", body)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _tipo(tipo: str) -> None:
|
|
109
|
+
if not re.fullmatch(r"[a-z0-9_]{3,60}", tipo):
|
|
110
|
+
raise ValueError("tipo deve ser um identificador do catálogo (GET nfe/eventos/contratos).")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _positive(v: int) -> None:
|
|
114
|
+
if not isinstance(v, int) or v < 1:
|
|
115
|
+
raise ValueError("invoice_id deve ser inteiro positivo.")
|
foxnfe/exceptions.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Exceções do SDK FOX NF-e."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FoxNfeException(Exception):
|
|
8
|
+
"""Exceção base do SDK."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str, context: Optional[dict[str, Any]] = None) -> None:
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.context = context
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiException(FoxNfeException):
|
|
16
|
+
"""Erro retornado pela API FOX NF-e."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, status_code: int, message: str, response_body: Optional[dict] = None) -> None:
|
|
19
|
+
super().__init__(message, response_body)
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self.response_body = response_body
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_response(cls, status_code: int, body: dict) -> "ApiException":
|
|
25
|
+
message = body.get("message") or body.get("error") or f"HTTP {status_code}"
|
|
26
|
+
return cls(status_code, message, body)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthException(FoxNfeException):
|
|
30
|
+
"""Erro de autenticação."""
|
foxnfe/mcp.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Módulo MCP do SDK FOX NF-e."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .types import McpTool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Mcp:
|
|
10
|
+
MCP_PATH = "mcp"
|
|
11
|
+
|
|
12
|
+
def __init__(self, client: Any) -> None:
|
|
13
|
+
self._client = client
|
|
14
|
+
|
|
15
|
+
def list_tools(self) -> list[McpTool]:
|
|
16
|
+
"""Lista todas as tools MCP disponíveis."""
|
|
17
|
+
resp = self._client.post(self.MCP_PATH, {
|
|
18
|
+
"jsonrpc": "2.0",
|
|
19
|
+
"id": 1,
|
|
20
|
+
"method": "tools/list",
|
|
21
|
+
"params": {},
|
|
22
|
+
})
|
|
23
|
+
return [McpTool.from_dict(t) for t in resp.get("result", {}).get("tools", [])]
|
|
24
|
+
|
|
25
|
+
def call_tool(self, tool: str, input: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
26
|
+
"""Chama uma tool MCP pelo nome."""
|
|
27
|
+
return self._client.post(self.MCP_PATH, {
|
|
28
|
+
"jsonrpc": "2.0",
|
|
29
|
+
"id": 2,
|
|
30
|
+
"method": "tools/call",
|
|
31
|
+
"params": {"name": tool, "arguments": input or {}},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
def initialize(self) -> dict[str, Any]:
|
|
35
|
+
"""Retorna informações do servidor MCP."""
|
|
36
|
+
return self._client.post(self.MCP_PATH, {
|
|
37
|
+
"jsonrpc": "2.0",
|
|
38
|
+
"id": 0,
|
|
39
|
+
"method": "initialize",
|
|
40
|
+
"params": {
|
|
41
|
+
"protocolVersion": "2024-11-05",
|
|
42
|
+
"clientInfo": {"name": "foxnfe-python-sdk", "version": "1.0.0"},
|
|
43
|
+
"capabilities": {},
|
|
44
|
+
},
|
|
45
|
+
})
|
foxnfe/nfe.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Módulo NF-e do SDK FOX NF-e."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from .exceptions import FoxNfeException
|
|
9
|
+
from .types import NfeEmitRequest, NfeResource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Nfe:
|
|
13
|
+
def __init__(self, client: Any) -> None:
|
|
14
|
+
self._client = client
|
|
15
|
+
|
|
16
|
+
def emit(self, payload: NfeEmitRequest) -> dict[str, Any]:
|
|
17
|
+
"""Emite uma NF-e. Retorna imediatamente com status 'pending'."""
|
|
18
|
+
return self._client.post("nfe/emit", payload.to_dict())
|
|
19
|
+
|
|
20
|
+
def get(self, nfe_id: int) -> NfeResource:
|
|
21
|
+
"""Consulta status e dados de uma NF-e."""
|
|
22
|
+
data = self._client.get(f"nfe/{nfe_id}")
|
|
23
|
+
return NfeResource.from_dict(data)
|
|
24
|
+
|
|
25
|
+
def xml(self, nfe_id: int) -> bytes:
|
|
26
|
+
"""Baixa o XML assinado da NF-e autorizada."""
|
|
27
|
+
return self._client.download(f"nfe/{nfe_id}/xml")
|
|
28
|
+
|
|
29
|
+
def pdf(self, nfe_id: int) -> bytes:
|
|
30
|
+
"""Baixa o DANFE em PDF."""
|
|
31
|
+
return self._client.download(f"nfe/{nfe_id}/pdf")
|
|
32
|
+
|
|
33
|
+
def cancel(self, nfe_id: int, justificativa: str) -> NfeResource:
|
|
34
|
+
"""Cancela uma NF-e autorizada. Justificativa mínima: 15 chars."""
|
|
35
|
+
data = self._client.post(f"nfe/{nfe_id}/cancel", {"justificativa": justificativa})
|
|
36
|
+
return NfeResource.from_dict(data)
|
|
37
|
+
|
|
38
|
+
# ── 1.3.0 — rejeições explicadas e homologação por modelo (L20) ──────────
|
|
39
|
+
|
|
40
|
+
def rejeicoes(self) -> dict[str, Any]:
|
|
41
|
+
"""Catálogo de rejeições SEFAZ: categoria, ação e dica por cStat."""
|
|
42
|
+
return self._client.get("nfe/rejeicoes")
|
|
43
|
+
|
|
44
|
+
def rejeicao(self, cstat: int | str) -> dict[str, Any]:
|
|
45
|
+
c = str(cstat)
|
|
46
|
+
if not re.fullmatch(r"\d{3}", c):
|
|
47
|
+
raise ValueError("cstat deve ter 3 dígitos.")
|
|
48
|
+
return self._client.get(f"nfe/rejeicoes/{c}")
|
|
49
|
+
|
|
50
|
+
def homologacao(self) -> dict[str, Any]:
|
|
51
|
+
"""Matriz de homologação (modelo/UF/cenário) e últimas corridas."""
|
|
52
|
+
return self._client.get("nfe/homologacao")
|
|
53
|
+
|
|
54
|
+
def homologacao_run(self, modelo: int, cenario: Optional[str] = None, mode: str = "simulated") -> dict[str, Any]:
|
|
55
|
+
"""simulated (padrão): amostras XML/PDF assinadas sem transmitir; sefaz: só com autorização no servidor (409)."""
|
|
56
|
+
if modelo not in (55, 65):
|
|
57
|
+
raise ValueError("modelo deve ser 55 ou 65.")
|
|
58
|
+
if mode not in ("simulated", "sefaz"):
|
|
59
|
+
raise ValueError("mode deve ser 'simulated' ou 'sefaz'.")
|
|
60
|
+
body: dict[str, Any] = {"modelo": modelo, "mode": mode}
|
|
61
|
+
if cenario:
|
|
62
|
+
body["cenario"] = cenario
|
|
63
|
+
return self._client.post("nfe/homologacao/run", body)
|
|
64
|
+
|
|
65
|
+
def homologacao_xml(self, run_id: int) -> bytes:
|
|
66
|
+
_positive(run_id)
|
|
67
|
+
return self._client.download(f"nfe/homologacao/{run_id}/xml")
|
|
68
|
+
|
|
69
|
+
def homologacao_pdf(self, run_id: int) -> bytes:
|
|
70
|
+
_positive(run_id)
|
|
71
|
+
return self._client.download(f"nfe/homologacao/{run_id}/pdf")
|
|
72
|
+
|
|
73
|
+
def wait_for_authorization(
|
|
74
|
+
self,
|
|
75
|
+
nfe_id: int,
|
|
76
|
+
max_wait_s: float = 120.0,
|
|
77
|
+
poll_s: float = 3.0,
|
|
78
|
+
) -> NfeResource:
|
|
79
|
+
"""Aguarda até que a NF-e saia do status 'pending'."""
|
|
80
|
+
deadline = time.monotonic() + max_wait_s
|
|
81
|
+
|
|
82
|
+
while time.monotonic() < deadline:
|
|
83
|
+
nfe = self.get(nfe_id)
|
|
84
|
+
if nfe.status != "pending":
|
|
85
|
+
return nfe
|
|
86
|
+
time.sleep(poll_s)
|
|
87
|
+
|
|
88
|
+
raise FoxNfeException(f"NF-e {nfe_id} ainda pendente após {max_wait_s}s.")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _positive(v: int) -> None:
|
|
92
|
+
if not isinstance(v, int) or v < 1:
|
|
93
|
+
raise ValueError("id deve ser inteiro positivo.")
|