Skip to content

Commit bc13784

Browse files
committed
add optional DF-e SOAP client
1 parent 92f1816 commit bc13784

File tree

2 files changed

+278
-0
lines changed

2 files changed

+278
-0
lines changed

nfelib/nfe/client/v4_0/dfe.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# Copyright (C) 2019 Luis Felipe Mileo - KMEE
2+
# Copyright (C) 2025 Raphaël Valyi - Akretion
3+
4+
import logging
5+
from typing import Any, Optional
6+
7+
from brazil_fiscal_client.fiscal_client import (
8+
FiscalClient,
9+
Tamb,
10+
)
11+
12+
# --- Content Bindings ---
13+
from nfelib.nfe.client.v4_0.servers import Endpoint
14+
15+
# --- Server Definitions ---
16+
from nfelib.nfe.client.v4_0.servers import servers as SERVERS_NFE
17+
18+
# --- SOAP Bindings ---
19+
from nfelib.nfe.soap.v4_0.nfedistribuicaodfe import (
20+
NfeDistribuicaoDfeSoapNfeDistDfeInteresse,
21+
)
22+
23+
# --- Dist DF-e ---
24+
from nfelib.nfe_dist_dfe.bindings.v1_0 import DistDfeInt, RetDistDfeInt
25+
26+
_logger = logging.getLogger(__name__)
27+
28+
29+
class DfeClient(FiscalClient):
30+
"""A façade for the NFe SOAP webservices."""
31+
32+
def __init__(self, **kwargs: Any):
33+
self.mod = kwargs.pop("mod", "55")
34+
super().__init__(
35+
service="nfe",
36+
versao="4.00",
37+
**kwargs,
38+
)
39+
40+
def _get_location(self, endpoint_type: Endpoint) -> str:
41+
"""Construct the full HTTPS URL for the specified service."""
42+
server_key = "AN"
43+
try:
44+
server_data = SERVERS_NFE[server_key]
45+
except KeyError:
46+
raise ValueError(
47+
f"No server configuration found for key: {server_key} "
48+
"(derived from UF {self.uf})"
49+
)
50+
51+
if self.ambiente == Tamb.PROD.value:
52+
server_host = server_data["prod_server"]
53+
else:
54+
server_host = server_data["dev_server"]
55+
56+
try:
57+
path = server_data["endpoints"][endpoint_type]
58+
except KeyError:
59+
raise ValueError(
60+
f"Endpoint {endpoint_type.name} not configured for server key: "
61+
"{server_key}"
62+
)
63+
64+
location = f"https://{server_host}{path}"
65+
_logger.debug(
66+
f"Determined location for {endpoint_type.name} (UF: {self.uf}, "
67+
"Amb: {self.ambiente}): {location}"
68+
)
69+
return location
70+
71+
def send(
72+
self,
73+
action_class: type,
74+
obj: Any,
75+
placeholder_exp: Optional[str] = None,
76+
placeholder_content: Optional[str] = None,
77+
**kwargs: Any,
78+
) -> Any:
79+
"""Build and send a request for the input object.
80+
81+
Args:
82+
action_class: type generated with xsdata for the SOAP wsdl action
83+
(e.g., NfeStatusServico4SoapNfeStatusServicoNf).
84+
obj: The *content* model instance (e.g., ConsStatServ) or a dictionary.
85+
This will be wrapped inside nfeDadosMsg.
86+
placeholder_content: A string content to be injected in the payload.
87+
Used for signed content to avoid signature issues.
88+
placeholder_exp: Placeholder expression where to inject placeholder_content.
89+
kwargs: Additional keyword arguments for FiscalClient.send.
90+
91+
Returns:
92+
The *content* response model instance (e.g., RetConsStatServ).
93+
"""
94+
try:
95+
# Determine the correct endpoint enum based on the action class
96+
action_to_endpoint_map: dict[type, Endpoint] = {
97+
NfeDistribuicaoDfeSoapNfeDistDfeInteresse: Endpoint.NFEDISTRIBUICAODFE,
98+
}
99+
endpoint_type = action_to_endpoint_map[action_class]
100+
location = self._get_location(endpoint_type)
101+
102+
except KeyError:
103+
raise ValueError(
104+
"Could not determine Endpoint for action_class: {action_class.__name__}"
105+
)
106+
107+
wrapped_obj: dict[str, Any]
108+
if isinstance(obj, DistDfeInt):
109+
wrapped_obj = {
110+
"Body": {"nfeDistDFeInteresse": {"nfeDadosMsg": {"content": [obj]}}}
111+
}
112+
else:
113+
wrapped_obj = {"Body": {"nfeDadosMsg": {"content": [obj]}}}
114+
115+
response = super().send(
116+
action_class,
117+
location,
118+
wrapped_obj,
119+
placeholder_exp=placeholder_exp,
120+
placeholder_content=placeholder_content,
121+
**kwargs,
122+
)
123+
124+
result_container = (
125+
response.body.nfeDistDFeInteresseResponse.nfeDistDFeInteresseResult
126+
)
127+
128+
if not self.wrap_response:
129+
return result_container.content[0]
130+
131+
response.resposta = result_container.content[0]
132+
133+
return response
134+
135+
def consultar_distribuicao(
136+
self,
137+
cnpj_cpf: str,
138+
ultimo_nsu: str = "",
139+
nsu_especifico: str = "",
140+
chave: str = "",
141+
) -> Optional[RetDistDfeInt]:
142+
"""Consultar Distribução de NFe.
143+
144+
:param cnpj_cpf: CPF ou CNPJ a ser consultado
145+
:param ultimo_nsu: Último NSU para pesquisa. Formato: '999999999999999'
146+
:param nsu_especifico: NSU Específico para pesquisa.
147+
Formato: '999999999999999'
148+
:param chave: Chave de acesso do documento
149+
:return: Retorna uma estrutura contendo as estruturas de envio
150+
e retorno preenchidas
151+
"""
152+
if not ultimo_nsu and not nsu_especifico and not chave:
153+
return None
154+
155+
distNSU = consNSU = consChNFe = None
156+
if ultimo_nsu:
157+
distNSU = DistDfeInt.DistNsu(ultNSU=ultimo_nsu)
158+
if nsu_especifico:
159+
consNSU = DistDfeInt.ConsNsu(NSU=nsu_especifico)
160+
if chave:
161+
consChNFe = DistDfeInt.ConsChNfe(chNFe=chave)
162+
163+
if (distNSU and consNSU) or (distNSU and consChNFe) or (consNSU and consChNFe):
164+
# TODO: Raise?
165+
return None
166+
167+
return self.send(
168+
NfeDistribuicaoDfeSoapNfeDistDfeInteresse,
169+
DistDfeInt(
170+
versao=self.versao,
171+
tpAmb=self.ambiente,
172+
cUFAutor=self.uf,
173+
CNPJ=cnpj_cpf if len(cnpj_cpf) > 11 else None,
174+
CPF=cnpj_cpf if len(cnpj_cpf) <= 11 else None,
175+
distNSU=distNSU,
176+
consNSU=consNSU,
177+
consChNFe=consChNFe,
178+
),
179+
)

tests/nfe/test_client_dfe.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import sys
2+
from unittest import TestCase, mock, skipIf
3+
4+
from erpbrasil.assinatura import misc
5+
from xsdata.formats.dataclass.transports import DefaultTransport
6+
7+
# --- Conditional Imports for Python 3.9+ ---
8+
# The DfeClient module uses syntax not supported in Python 3.8 (e.g., dict[str, Any]).
9+
# We must prevent the import from happening to avoid a SyntaxError/RuntimeError.
10+
if sys.version_info >= (3, 9):
11+
from nfelib.nfe.client.v4_0.dfe import DfeClient
12+
from nfelib.nfe_dist_dfe.bindings.v1_0 import RetDistDfeInt
13+
else:
14+
# Define dummies so the class definition below doesn't throw a NameError
15+
DfeClient = None
16+
RetDistDfeInt = None
17+
18+
19+
# --- Mock SOAP Response ---
20+
# A realistic SOAP response for a successful query with multiple documents.
21+
response_sucesso_multiplos = b"""<?xml version="1.0" encoding="UTF-8"?>
22+
<soap:Envelope xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
23+
<soap:Body>
24+
<nfeDistDFeInteresseResponse xmlns="http://www.portalfiscal.inf.br/nfe/wsdl/NFeDistribuicaoDFe">
25+
<nfeDistDFeInteresseResult>
26+
<retDistDFeInt xmlns="http://www.portalfiscal.inf.br/nfe" versao="1.01">
27+
<tpAmb>1</tpAmb>
28+
<verAplic>1.4.0</verAplic>
29+
<cStat>138</cStat>
30+
<xMotivo>Documento(s) localizado(s)</xMotivo>
31+
<dhResp>2022-04-04T11:54:49-03:00</dhResp>
32+
<ultNSU>000000000000201</ultNSU>
33+
<maxNSU>000000000000201</maxNSU>
34+
<loteDistDFeInt>
35+
<docZip NSU="000000000000200" schema="resNFe_v1.00.xsd">H4sIAAAAAAAEAIVS22qDQBD9FfFdd9Z7ZLKQphosqQ3mQuibMZto8RJcifn8rjG9PZUdZg7DOWeGYbHlIg65cqvKWvg3cZyqedddfEL6vtd7U2/aMzEAKNm/LtdZzqtU/SYX/5O1ohZdWmdcVa68FWkzVakO8PD4o780bZeWp0JkaakX9Uk/tKQ+cZVhlssVmUkNoPLZnjcAGKBtDwVMzzIodak3AIO6HpJRg/N49cL+apDcm3iLm4qz99lKWSSzMJrPlEAJnqPNWyJRlATLCMnIwShgUkqpNLEAHBOJ7OAxD6qCGWCARkEDZwPg30MDU2YkIwG7SxwyiuRe8SqTN3H1iXQZMB6L8y4t2W73sXdtJ+6TUDhGveaLbc9DsXyyt1NpNZLkzIRnh675PZZOfMP2LfNn7IOD9aptOkaHy5meDS44FnWRjG3M1kU3HEmu9gWRjP+BfQI6BY33GAIAAA==</docZip>
36+
<docZip NSU="000000000000201" schema="procNFe_v4.00.xsd">H4sIAAAAAAAAA51WzXKjRhC+5ykoX1MWMyAssTWeiozQhpSFKEu7dwxjmwQYLUJYldfJOS+QY/bF0t0DWF5nt7JRqejub3q6p3/mR9QPKml0ZnWqOaT6+mI6YezCOlVlfbi+eGrb/Tvbfn5+nux106blQ3HI0nJS1A+T+8aGuRdSxCv1Xfog4JTXDqP8+gJQ13MY457v+VOXewz5mQeUs/7HHXblzGYzfsXRVK6kyD6spOsJG6nI4pUcNAACSdRpu9nLj6rOU2EbQVQ6lx7MQSoOqimU5MI2jKhhFkhIRP4UVoV0mMMuGYf/jjvvGIP/j4zDV9hGAfS2aRHW7bdVex3R7o0LohDFUh1alHtOZOtjvXoPUTHOPQfiMDLMi6q9mYgMyOD8YADiRLb8iCISGF1U99LBQWTEQwEhUaA9B6XIV0WdluR74BFNGnWQjEBixR56BAMFbGAFVBBbR25yra2bJj0UpbUJFlbHp8IeBjEo8KSqAuIK4uQX+bq6wiZQnGJdKbkLt7vQurS2RbUv1cGK06zQsChhm3FxWqWQwG+o0biAYqsmJJ+n28dG3h1TK0mPpbaWRXoANQRF3WjpzaFPkKGkv045TMbvojxWn/+sCw3zCIVG2ybCxn4LwkTyOXcwGggFJJElKdaEeXOwQrw4ETEpAiMGfNC1kg53obl9/wqakQBhn609CiW0v+vOwT53XWEDIIK7HdYLCSiTXk5dQ4mc86nv+jMfszv3X2c3Xl2GVriOdtFyAdRarG+iMIZMLkPr5816c7t5vwgWG0wsjH5c3G7urFW0DRa3Y/5pcaZJx8Ru0+qoSmutm4M6Ty3HXUmpPQX7EnbG339ZKezCBhwEuv71WLfa4h7EQuPidJMWDajfNFr/VhY14D0y1MZjLpu/qs328x/aVPYrxWFTb3bFrv5XcTyPc3c6mzvQrK/LYzIAuyMKx707Cli26RWbOj6cQq47NWVTVVqUMisLVbeK/zQwk0xXcDZiJXEcTgkykavWqqNWVdcXeNDBnstx8UjCy2Cz5rjJE4OGi1hiwd7vohhQFCEoHAvS+6IGS89F+2QtNRQIA6RZcbCW/pS5LjUuSiJYbRLpcQbdT6w4BrqSH+JoKWxixSf88gmjOSSI7kNN4HTCxh/sfoOKjpzRIIAv6901xf0XayZIHIn0Pg30icjo1YDgwMBv/JpxqMZOD3VBjs4t8A4nhj600FJRsN6a7zbmjEuhm+IRzzeiIthutjE0Cu40YsU+aFQOjDOZka9BFh0yxpBkExc66xyB6r/4sHuvSYR5qD9J3/cxfOAQjHfoeCc9F73i/u5Bm2Yk0ZY+m2PbGMWp3yt2NwH4phQAJ/aoyvqUkQCl6CEsBAL2aMkmOdisonik/8FHP2F0MxjozgYwFz1snxu2R3QsCHQ+Xo26xTsI80Rl+8JpRwnsAZNMIrDxdH2OG0B0qyAZYGTRHsQyWqS4XgASQe8FMUIP3sEKz3GU/7XHu1WjWjXqkgB+1OPoCFjRwSKzASEegonGKCIUkxc56YGl6nR5hhr5bYG/UocOK6AH1AiiwwdJHwK+SeyxAHZfkbZJ64N5Opl4fHo+9bHZw/A+faTTK0GKz4f0cXhIIEI4biqj0OF3TB0i9jDX3hsLD4u8yHpmBc9JLZc6O1YKLw+8/YpcW/DYfGet0yazlqrS6G1U7oUiMxw9e2z6wnnQvnmIkiOoYUsv0iiHO8xx+NxxvKnD5t7F21dV9oTWvufhChue5ogaHckvXMCVSbDItm0Ko5gaw8KNp9ui03JxbOGQ+j2FyLV1PGgrTy242/Hy7TUoVmPG7uMErn/ryx/+AZs2W+n2CwAA</docZip>
37+
</loteDistDFeInt>
38+
</retDistDFeInt>
39+
</nfeDistDFeInteresseResult>
40+
</nfeDistDFeInteresseResponse>
41+
</soap:Body>
42+
</soap:Envelope>"""
43+
44+
45+
@skipIf(sys.version_info < (3, 9), "DfeClient requires Python 3.9+")
46+
class DfeClientTest(TestCase):
47+
"""Tests DfeClient SOAP interactions."""
48+
49+
@classmethod
50+
def setUpClass(cls):
51+
super().setUpClass()
52+
cls.cert_password = "testpassword"
53+
cls.cert_data = misc.create_fake_certificate_file(
54+
valid=True,
55+
passwd=cls.cert_password,
56+
issuer="TEST ISSUER",
57+
country="BR",
58+
subject="TEST SUBJECT",
59+
)
60+
cls.fake_certificate = True
61+
62+
# Client Setup
63+
cls.client = DfeClient(
64+
ambiente="1", # DF-e distribution is only available in production
65+
uf="35", # The UF of the interested party (CNPJ/CPF)
66+
pkcs12_data=cls.cert_data,
67+
pkcs12_password=cls.cert_password,
68+
fake_certificate=cls.fake_certificate,
69+
verify_ssl=False,
70+
)
71+
72+
@mock.patch.object(DefaultTransport, "post")
73+
def test_consultar_distribuicao_mocked(self, mock_post):
74+
"""
75+
Tests the DF-e distribution query with a mocked successful response.
76+
"""
77+
mock_post.return_value = response_sucesso_multiplos
78+
79+
# Define test parameters
80+
cnpj_cpf = "00000000000191"
81+
ultimo_nsu = "000000000000000"
82+
83+
# Call the client method
84+
res = self.client.consultar_distribuicao(
85+
cnpj_cpf=cnpj_cpf, ultimo_nsu=ultimo_nsu
86+
)
87+
88+
# Assertions
89+
self.assertIsInstance(res, RetDistDfeInt)
90+
self.assertEqual(res.cStat, "138")
91+
self.assertEqual(res.xMotivo, "Documento(s) localizado(s)")
92+
self.assertEqual(res.ultNSU, "000000000000201")
93+
self.assertIsNotNone(res.loteDistDFeInt)
94+
self.assertEqual(len(res.loteDistDFeInt.docZip), 2)
95+
self.assertEqual(res.loteDistDFeInt.docZip[0].NSU, "000000000000200")
96+
self.assertEqual(res.loteDistDFeInt.docZip[1].NSU, "000000000000201")
97+
98+
# Verify that the mock was called
99+
mock_post.assert_called_once()

0 commit comments

Comments
 (0)