diff --git a/nfelib/cte/client/v4_0/cte.py b/nfelib/cte/client/v4_0/cte.py new file mode 100644 index 00000000..59c289d4 --- /dev/null +++ b/nfelib/cte/client/v4_0/cte.py @@ -0,0 +1,157 @@ +# FILEPATH: nfelib/cte/client/v4_0/cte.py +# Copyright (C) 2025 Raphaël Valyi - Akretion + +import logging +from typing import Any + +from brazil_fiscal_client.fiscal_client import FiscalClient, Tamb +from lxml import etree + +# --- Content Bindings --- +from nfelib.cte.bindings.v4_0.cons_sit_cte_v4_00 import ConsSitCte +from nfelib.cte.bindings.v4_0.cons_stat_serv_cte_v4_00 import ConsStatServCte +from nfelib.cte.bindings.v4_0.cte_tipos_basico_v4_00 import Tcte +from nfelib.cte.bindings.v4_0.ret_cons_sit_cte_v4_00 import RetConsSitCte +from nfelib.cte.bindings.v4_0.ret_cons_stat_serv_cte_v4_00 import RetConsStatServCte +from nfelib.cte.bindings.v4_0.ret_cte_v4_00 import RetCte + +# --- Server Definitions --- +from nfelib.cte.client.v4_0.servers import Endpoint +from nfelib.cte.client.v4_0.servers import servers as SERVERS_CTE + +# --- SOAP Bindings --- +from nfelib.cte.soap.v4_0.cteconsultav4 import CteConsultaV4Soap12CteConsultaCt +from nfelib.cte.soap.v4_0.cterecepcaosincv4 import CteRecepcaoSincV4Soap12CteRecepcao +from nfelib.cte.soap.v4_0.ctestatusservicov4 import ( + CteStatusServicoV4Soap12CteStatusServicoCt, +) + +_logger = logging.getLogger(__name__) + + +def _get_server_key_for_uf(uf_ibge_code: str) -> str: + """Gets the server key ('MT', 'SVRS', etc.) for a given UF.""" + uf_map = { + "51": "MT", + "50": "MS", + "31": "MG", + "41": "PR", + "43": "RS", + "35": "SP", + "16": "AP", + "26": "PE", + "14": "RR", + } + # Fallback to SVRS for many states + return uf_map.get(uf_ibge_code, "SVRS") + + +# TODO The event methods in CteClient should follow the same high-level pattern +# proposed for NfeClient: accept data primitives (chave, protocolo, etc.) and +# handle the creation and signing of the detEvento internally. + + +class CteClient(FiscalClient): + """A façade for the CTe v4.00 SOAP webservices.""" + + def __init__(self, **kwargs: Any): + # Default to model 57 (CT-e) if not specified + self.mod = kwargs.pop("mod", "57") + self.soap12_envelope = (True,) + super().__init__(service="cte", versao="4.00", **kwargs) + + def _get_location(self, endpoint_type: Endpoint) -> str: + """Constructs the full HTTPS URL for the specified service.""" + server_key = _get_server_key_for_uf(self.uf) + try: + server_data = SERVERS_CTE[server_key] + except KeyError: + raise ValueError( + f"No server configuration found for key: {server_key} (derived from UF {self.uf})" + ) + + server_host = ( + server_data["prod_server"] + if self.ambiente == Tamb.PROD.value + else server_data["dev_server"] + ) + path = server_data["endpoints"][endpoint_type] + location = f"https://{server_host}{path}" + _logger.debug(f"Determined location for {endpoint_type.name}: {location}") + return location + + def send( + self, + action_class: type, + obj: Any, + **kwargs: Any, + ) -> Any: + """Builds and sends a request, wrapping the payload in cteDadosMsg.""" + action_to_endpoint_map = { + CteStatusServicoV4Soap12CteStatusServicoCt: Endpoint.CTESTATUSSERVICOV4, + CteConsultaV4Soap12CteConsultaCt: Endpoint.CTECONSULTAV4, + CteRecepcaoSincV4Soap12CteRecepcao: Endpoint.CTERECEPCAOSINCV4, + } + endpoint_type = action_to_endpoint_map[action_class] + location = self._get_location(endpoint_type) + + if isinstance(obj, Tcte): + wrapped_obj = {"Body": {"cteDadosMsg": {"value": obj}}} + else: + wrapped_obj = {"Body": {"cteDadosMsg": {"content": [obj]}}} + + response = super().send( + action_class=action_class, + location=location, + wrapped_obj=wrapped_obj, + **kwargs, + ) + + # The actual result is nested inside the response structure + if not self.wrap_response: + if isinstance(obj, ConsStatServCte): + return response.body.cteStatusServicoCTResult.content[0] + return response.body.content[0].content[0] + + if isinstance(obj, ConsStatServCte): + response.resposta = response.body.cteStatusServicoCTResult.content + else: + response.resposta = response.resposta.body.content[0].content[0] + return response + + def status_servico(self) -> RetConsStatServCte: + """Consulta o status do serviço CT-e.""" + payload = ConsStatServCte(tpAmb=Tamb(self.ambiente), versao=self.versao) + return self.send(CteStatusServicoV4Soap12CteStatusServicoCt, payload) + + def consulta_documento( + self, chave: str + ) -> RetConsSitCte: # NOTE consulta_documento in erpbrasil + """Consulta a situação de um CT-e pela chave de acesso.""" + payload = ConsSitCte(tpAmb=Tamb(self.ambiente), chCTe=chave, versao=self.versao) + return self.send(CteConsultaV4Soap12CteConsultaCt, payload) + + def envia_documento( + self, cte_obj: Tcte + ) -> RetCte: # NOTE enviar_documento in erpbrasil + """Envia um único CT-e de forma síncrona. + + Args: + cte_obj: The Tcte object to be signed and sent. + + Returns: + The processing result from the SEFAZ. + """ + signed_xml = cte_obj.to_xml( + pkcs12_data=self.pkcs12_data, + pkcs12_password=self.pkcs12_password, + doc_id=cte_obj.infCte.Id, + ) + # FIXME this is wrong, see erpbrasil envia_documento and gzip + + # The webservice expects the signed CTe object directly inside cteDadosMsg + return self.send( + CteRecepcaoSincV4Soap12CteRecepcao, etree.fromstring(signed_xml) + ) + + # TODO enviar_lote_evento, cancela_documento, carta_correcao, consulta_recibo, get_documento_id, monta_qrcode, monta_cte_proc diff --git a/nfelib/mdfe/client/v3_0/__init__.py b/nfelib/mdfe/client/v3_0/__init__.py index e69de29b..4336986e 100644 --- a/nfelib/mdfe/client/v3_0/__init__.py +++ b/nfelib/mdfe/client/v3_0/__init__.py @@ -0,0 +1,442 @@ +import base64 +import gzip +import logging +from datetime import datetime +from typing import Any, Optional + +from brazil_fiscal_client.fiscal_client import FiscalClient, Tamb, TcodUfIbge + +from nfelib.mdfe.bindings.v3_0 import ( + ConsStatServMdfe, + EvCancMdfe, + EvEncMdfe, + EventoMdfe, + RetConsSitMdfe, + RetEventoMdfe, + RetMdfe, +) + +# --- Content Bindings --- +# Import necessary MDFe content bindings (adjust based on actual methods implemented) +from nfelib.mdfe.bindings.v3_0.cons_mdfe_nao_enc_v3_00 import ConsMdfeNaoEnc + +# Import MDFe Base Types needed for request construction +# from nfelib.mdfe.bindings.v3_0.cons_stat_serv_tipos_basico_v3_00 import TconsStatServ +from nfelib.mdfe.bindings.v3_0.cons_sit_mdfe_v3_00 import ConsSitMdfe +from nfelib.mdfe.bindings.v3_0.cons_stat_serv_mdfe_v3_00 import ConsStatServMdfe +from nfelib.mdfe.bindings.v3_0.dist_mdfe_v3_00 import DistMdfe +from nfelib.mdfe.bindings.v3_0.envi_mdfe_v3_00 import EnviMdfe +from nfelib.mdfe.bindings.v3_0.ev_canc_mdfe_v3_00 import EvCancMdfe +from nfelib.mdfe.bindings.v3_0.ev_enc_mdfe_v3_00 import EvEncMdfe + +# --- Event Bindings (Example for Cancel/Encerramento) --- +# Adapt imports based on which events you need +from nfelib.mdfe.bindings.v3_0.evento_mdfe_tipos_basico_v3_00 import ( + # TenvEvento as TenvEventoMdfe, # Renamed for clarity + # TretEvento as TretEventoMdfe, + Tevento as TeventoMdfe, +) +from nfelib.mdfe.bindings.v3_0.mdfe_tipos_basico_v3_00 import TenviMdfe, Tmdfe +from nfelib.mdfe.bindings.v3_0.mdfe_v3_00 import Mdfe +from nfelib.mdfe.bindings.v3_0.ret_cons_mdfe_nao_enc_v3_00 import RetConsMdfeNaoEnc +from nfelib.mdfe.bindings.v3_0.ret_cons_sit_mdfe_v3_00 import RetConsSitMdfe +from nfelib.mdfe.bindings.v3_0.ret_cons_stat_serv_mdfe_v3_00 import RetConsStatServMdfe +from nfelib.mdfe.bindings.v3_0.ret_dist_mdfe_v3_00 import RetDistMdfe +from nfelib.mdfe.bindings.v3_0.ret_envi_mdfe_v3_00 import RetEnviMdfe +from nfelib.mdfe.bindings.v3_0.ret_evento_mdfe_v3_00 import RetEventoMdfe +from nfelib.mdfe.bindings.v3_0.ret_mdfe_v3_00 import RetMdfe +from nfelib.mdfe.client.v3_0.servers import Endpoint + +# --- Server Definitions --- +# Import Endpoint Enum and the servers dictionary directly +from nfelib.mdfe.client.v3_0.servers import servers as SERVERS_MDFE + +# Add others as needed (ConsultaNaoEnc, DistDFe) +from nfelib.mdfe.soap.v3_0.mdfeconsnaoenc import ( + MdfeConsNaoEncSoap12MdfeConsNaoEnc, +) +from nfelib.mdfe.soap.v3_0.mdfeconsulta import ( + MdfeConsultaSoap12MdfeConsultaMdf, +) +from nfelib.mdfe.soap.v3_0.mdfedistribuicaodfe import ( + MdfeDistribuicaoDfeSoap12MdfeDistDfeInteresse, +) +from nfelib.mdfe.soap.v3_0.mdferecepcaoevento import ( + MdfeRecepcaoEventoSoap12MdfeRecepcaoEvento, +) +from nfelib.mdfe.soap.v3_0.mdferecepcaosinc import ( + MdfeRecepcaoSincSoap12MdfeRecepcao, +) + +# --- SOAP Bindings --- +# Import the MDFe SOAP binding classes +from nfelib.mdfe.soap.v3_0.mdfestatusservico import ( + MdfeStatusServicoSoap12MdfeStatusServicoMdf, +) + +# --- Logging --- +_logger = logging.getLogger(__name__) + +# --- Constants --- +MDFE_VERSION = "3.00" +# Define MDFe event types if needed +TIPO_EVENTO_CANCEL_MDFE = "110111" +TIPO_EVENTO_ENC_MDFE = "110112" +# ... other event types ... + + +class MdfeClient(FiscalClient): + """A façade for the MDFe v3.00 SOAP webservices.""" + + def __init__(self, **kwargs: Any): + # MDFe uses SVRS, so UF is primarily for the mdfeCabecMsg header + uf_code = kwargs.get("uf") + if not isinstance(uf_code, str) or len(uf_code) != 2 or not uf_code.isdigit(): + _logger.warning( + "MdfeClient initialized with potentially invalid " + "UF code: {uf_code}. Expected 2-digit string for header." + ) + # Should still work as server is fixed, but header needs valid UF + + super().__init__( + service="mdfe", # Service name for ns_map in prepare_payload + versao=MDFE_VERSION, + **kwargs, + ) + # MDFe always uses SVRS server key + self.server_key = "SVRS" + + def _get_location(self, endpoint_type: Endpoint) -> str: + """Constructs the full HTTPS URL for the specified service.""" + try: + server_data = SERVERS_MDFE[self.server_key] + except KeyError: + # Should not happen if servers.py is correct + raise ValueError( + "MDFe server configuration not foundfor key: {self.server_key}" + ) + + if self.ambiente == Tamb.PROD.value: + server_host = server_data["prod_server"] + else: + server_host = server_data["dev_server"] + + if endpoint_type not in server_data["endpoints"]: + raise ValueError( + f"Endpoint {endpoint_type.name} not configured " + "for server key: {self.server_key}" + ) + + path = server_data["endpoints"][endpoint_type] + location = f"https://{server_host}{path}" + _logger.debug( + f"Determined location for {endpoint_type.name} (Amb: {self.ambiente}, " + "ServerKey: {self.server_key}): {location}" + ) + return location + + def _get_header(self) -> Optional[dict]: + """Constructs the mdfeCabecMsg header.""" + # Most MDFe operations require this header + header_obj = { + # Assuming a common structure, adjust namespace if needed + "mdfeCabecMsg": {"cUF": self.uf, "versaoDados": self.versao} + } + # Some WSDLs might have slightly different header element names or namespaces + # Example: StatusServico uses 'mdfeCabecMsg' in the correct namespace + # Consulta uses 'mdfeCabecMsg' + # RecepcaoEvento uses 'mdfeCabecMsg' + # Check each SOAP binding's *Input class for the exact header structure + # if issues arise + _logger.debug(f"Generated MDFe Header: {header_obj}") + return header_obj + + def send( + self, + action_class: type, + payload_obj: Any, + requires_header: bool = True, + payload_is_base64: bool = False, # Flag for MDFeRecepcaoSinc + placeholder_exp: Optional[ + str + ] = None, # Not typically used with MDFe wrapper style? + placeholder_content: Optional[str] = None, + **kwargs: Any, + ) -> Any: + """Build and send an MDFe request. Handles header and base64 encoding.""" + try: + action_to_endpoint_map: dict[type, Endpoint] = { + MdfeStatusServicoSoap12MdfeStatusServicoMdf: Endpoint.MDFESTATUSSERVICO, + MdfeConsultaSoap12MdfeConsultaMdf: Endpoint.MDFECONSULTA, + MdfeRecepcaoSincSoap12MdfeRecepcao: Endpoint.MDFERECEPCAOSINC, + MdfeRecepcaoEventoSoap12MdfeRecepcaoEvento: Endpoint.MDFERECEPCAOEVENTO, + MdfeConsNaoEncSoap12MdfeConsNaoEnc: Endpoint.MDFECONSNAOENC, + MdfeDistribuicaoDfeSoap12MdfeDistDfeInteresse: ( + Endpoint.MDFEDISTRIBUICAODFE, + ), + } + endpoint_type = action_to_endpoint_map[action_class] + location = self._get_location(endpoint_type) + except KeyError: + raise ValueError( + "Could not determine Endpoint for action_class: {action_class.__name__}" + ) + + # Prepare the body content (mdfeDadosMsg wrapper) + if payload_is_base64: + # For RecepcaoSinc, the payload_obj is already the base64 string + if not isinstance(payload_obj, str): + raise TypeError( + "payload_obj must be a base64 string for MDFeRecepcaoSinc" + ) + body_content = {"mdfeDadosMsg": {"value": payload_obj}} + else: + # Standard case: wrap the content object + body_content = {"mdfeDadosMsg": {"content": [payload_obj]}} + + # Construct the full SOAP object including header if needed + wrapped_obj = {"Body": body_content} + if requires_header: + wrapped_obj["Header"] = self._get_header() + + response = super().send( + action_class, + location, + wrapped_obj, + placeholder_exp=placeholder_exp, + placeholder_content=placeholder_content, + **kwargs, + ) + return response.body.mdfeStatusServicoMDFResult.content[0] + + ###################################### + # Webservices + ###################################### + + def status_servico(self) -> Optional[RetConsStatServMdfe]: + """Consulta o status do serviço MDFe.""" + # MDFe Status request payload (TConsStatServ) doesn't need cUF inside + payload = ConsStatServMdfe( + tpAmb=Tamb(self.ambiente), + # xServ="STATUS" is implicitly set in TconsStatServ + versao=self.versao, + ) + return self.send( + MdfeStatusServicoSoap12MdfeStatusServicoMdf, payload, requires_header=True + ) + + def consulta_mdfe(self, chave: str) -> Optional[RetConsSitMdfe]: + """Consulta a situação de um MDF-e pela chave de acesso.""" + if not (isinstance(chave, str) and len(chave) == 44 and chave.isdigit()): + raise ValueError(f"Chave de acesso do MDFe inválida: {chave}") + + payload = ConsSitMdfe( + tpAmb=Tamb(self.ambiente), + # xServ="CONSULTAR" is implicitly set in TconsSitMdfe + chMDFe=chave, + versao=self.versao, + ) + return self.send( + MdfeConsultaSoap12MdfeConsultaMdf, payload, requires_header=True + ) + + def envia_mdfe_sincrono(self, signed_mdfe_xml: str) -> Optional[RetMdfe]: + """Envia um MDF-e assinado para autorização síncrona.""" + if not signed_mdfe_xml: + raise ValueError("XML do MDF-e assinado não pode estar vazio.") + + # RecepcaoSinc expects the XML directly in the mdfeDadosMsg value, not wrapped + # in content[] + # It also seems to NOT require the mdfeCabecMsg header based on WSDL/Legacy + + # Compress with Gzip and encode Base64 + try: + gzipped_xml = gzip.compress(signed_mdfe_xml.encode("utf-8")) + base64_gzipped_xml = base64.b64encode(gzipped_xml).decode("utf-8") + except Exception as e: + _logger.error(f"Erro ao comprimir/codificar XML do MDF-e: {e}") + raise + + # Pass the base64 string directly as the payload_obj + return self.send( + MdfeRecepcaoSincSoap12MdfeRecepcao, + payload_obj=base64_gzipped_xml, + requires_header=False, # Sincrono usually doesn't have the header + payload_is_base64=True, # Signal to send to handle it differently + ) + + def envia_evento( + self, evento, tipo, chave, sequencia="001", data_hora: str = False + ): + EventoMdfe( + versao="3.00", + infEvento=EventoMdfe.InfEvento( + Id="ID" + tipo + chave + sequencia.zfill(2), + cOrgao=self.uf, + tpAmb=self.ambiente, + CNPJ=chave[6:20], + chMDFe=chave, + dhEvento=data_hora or self._hora_agora(), + tpEvento=tipo, + nSeqEvento=sequencia, + detEvento=EventoMdfe.InfEvento.DetEvento( + versaoEvento="3.00", any_element=evento + ), + ), + ) + # FIXME: + return self.send( + MdfeRecepcaoEventoSoap12MdfeRecepcaoEvento, + payload_obj=payload_for_wrapping, # This needs to be wrapped + ) + + # --- Need to Refine FiscalClient.send for pre-signed --- + # Let's add a flag to FiscalClient.send to bypass prepare_payload if content is already XML + + def consulta_nao_encerrados( + self, cnpj: Optional[str] = None, cpf: Optional[str] = None + ) -> Optional[RetConsMdfeNaoEnc]: + """Consulta MDF-es não encerrados para um CNPJ/CPF.""" + if not cnpj and not cpf: + raise ValueError("É necessário fornecer CNPJ ou CPF para a consulta.") + if cnpj and cpf: + raise ValueError("Forneça apenas CNPJ ou CPF, não ambos.") + if cnpj and not (isinstance(cnpj, str) and len(cnpj) == 14 and cnpj.isdigit()): + raise ValueError(f"CNPJ inválido: {cnpj}") + if cpf and not (isinstance(cpf, str) and len(cpf) == 11 and cpf.isdigit()): + raise ValueError(f"CPF inválido: {cpf}") + + payload = ConsMdfeNaoEnc( + tpAmb=Tamb(self.ambiente), + # xServ="CONSULTAR NÃO ENCERRADOS" is implicitly set + CNPJ=cnpj, + CPF=cpf, + versao=self.versao, + ) + return self.send( + MdfeConsNaoEncSoap12MdfeConsNaoEnc, payload, requires_header=True + ) + + # --- Add other MDFe specific methods like consulta_recibo, distribuicao_dfe --- + + ###################################### + # Binding Façades (Input Assembly) - Adapt from Legacy/NFe if needed + ###################################### + + def FIXMEcancela_mdfe( + self, + chave: str, + protocolo_autorizacao: str, + justificativa: str, + cnpj_cpf_autor: Optional[str] = None, # CNPJ/CPF of the event author + data_hora_evento: Optional[str] = None, + sequencia: str = "1", + ) -> TeventoMdfe: + """Monta o objeto TeventoMdfe para um evento de cancelamento.""" + # ... (Input Validations like NFe cancela_documento) ... + if not (15 <= len(justificativa) <= 255): + raise ValueError("Justificativa deve ter entre 15 e 255 caracteres.") + + # Determine CNPJ or CPF of the event author + doc_autor = self._get_doc_autor(cnpj_cpf_autor, chave) + + # Detail specific to cancellation event + det_evento_canc = EvCancMdfe( + descEvento="Cancelamento", # Use Enum if defined + nProt=protocolo_autorizacao, + xJust=justificativa, + ) + + inf_evento = TeventoMdfe.InfEvento( + Id="ID" + TIPO_EVENTO_CANCEL_MDFE + chave + sequencia.zfill(2), + cOrgao=TcodUfIbge(self.uf), # Or specific organ code if needed + tpAmb=Tamb(self.ambiente), + CNPJ=doc_autor["CNPJ"], + CPF=doc_autor["CPF"], + chMDFe=chave, # Use chMDFe field name + dhEvento=data_hora_evento or self._timestamp(), + tpEvento=TIPO_EVENTO_CANCEL_MDFE, + nSeqEvento=sequencia, + detEvento=TeventoMdfe.InfEvento.DetEvento( + versaoEvento=self.versao, # Version of the detail schema + any_element=det_evento_canc, # Embed the specific event detail + ), + ) + return TeventoMdfe(versao=self.versao, infEvento=inf_evento) + + def encerra_mdfe( + self, + chave: str, + protocolo_autorizacao: str, + dt_enc: str, # Format YYYY-MM-DD + cod_uf_enc: str, # IBGE code for UF of encerrramento + cod_mun_enc: str, # IBGE code for Mun of encerrramento + cnpj_cpf_autor: Optional[str] = None, + data_hora_evento: Optional[str] = None, + sequencia: str = "1", + ) -> TeventoMdfe: + """Monta o objeto TeventoMdfe para um evento de encerramento.""" + # ... (Input Validations) ... + try: + datetime.strptime(dt_enc, "%Y-%m-%d") + except ValueError: + raise ValueError("Formato inválido para dtEnc. Use AAAA-MM-DD.") + # Validate cod_uf_enc, cod_mun_enc formats/values... + + doc_autor = self._get_doc_autor(cnpj_cpf_autor, chave) + + det_evento_enc = EvEncMdfe( + descEvento="Encerramento", # Use Enum if defined + nProt=protocolo_autorizacao, + dtEnc=dt_enc, + cUF=TcodUfIbge(cod_uf_enc), # Make sure TcodUfIbge includes all UFs + cMun=cod_mun_enc, + ) + + inf_evento = TeventoMdfe.InfEvento( + Id="ID" + TIPO_EVENTO_ENC_MDFE + chave + sequencia.zfill(2), + cOrgao=TcodUfIbge( + self.uf + ), # Or UF of encerrramento? Check docs. Usually UF emitting event. + tpAmb=Tamb(self.ambiente), + CNPJ=doc_autor["CNPJ"], + CPF=doc_autor["CPF"], + chMDFe=chave, + dhEvento=data_hora_evento or self._timestamp(), + tpEvento=TIPO_EVENTO_ENC_MDFE, + nSeqEvento=sequencia, + detEvento=TeventoMdfe.InfEvento.DetEvento( + versaoEvento=self.versao, any_element=det_evento_enc + ), + ) + return TeventoMdfe(versao=self.versao, infEvento=inf_evento) + + # --- Add other façade methods (inclusao condutor, inclusao DFe, etc.) --- + + # --- Helper for Author CNPJ/CPF --- + def _get_doc_autor( + self, cnpj_cpf_autor: Optional[str], chave: str + ) -> dict[str, Optional[str]]: + """Determines the CNPJ/CPF of the event author.""" + if cnpj_cpf_autor: + if len(cnpj_cpf_autor) == 14 and cnpj_cpf_autor.isdigit(): + return {"CNPJ": cnpj_cpf_autor, "CPF": None} + if len(cnpj_cpf_autor) == 11 and cnpj_cpf_autor.isdigit(): + return {"CNPJ": None, "CPF": cnpj_cpf_autor} + raise ValueError("CNPJ/CPF do autor do evento inválido.") + # Fallback to extracting from chave (assuming CNPJ) + _logger.warning( + "CNPJ/CPF do autor não fornecido, extraindo da chave (assumindo CNPJ)." + ) + return {"CNPJ": chave[6:20], "CPF": None} + + # --- Overwrite timestamp if MDFe format differs (unlikely) --- + # @classmethod + # def _timestamp(cls): + # return super()._timestamp() # Or specific MDFe format + + # --- Need _aguarda_tempo_medio equivalent? MDFe RecepcaoSinc doesn't return tMed --- + # def _aguarda_tempo_medio(self, proc_recibo: Optional[RetEnviMdfe]): + # # MDFe sync response (RetMdfe) doesn't have tMed. Async (RetConsReciMDFe) does. + # # This method might only be relevant if using async submission. + # pass diff --git a/nfelib/mdfe/client/v3_0/mdfe.py b/nfelib/mdfe/client/v3_0/mdfe.py new file mode 100644 index 00000000..5f920da2 --- /dev/null +++ b/nfelib/mdfe/client/v3_0/mdfe.py @@ -0,0 +1,272 @@ +# FILEPATH: nfelib/mdfe/client/v3_0/mdfe.py +# Copyright (C) 2019 Luis Felipe Mileo - KMEE +# Copyright (C) 2025 Raphaël Valyi - Akretion + +import binascii +import logging +from datetime import datetime +from typing import Any, Optional + +from brazil_fiscal_client.fiscal_client import FiscalClient, Tamb, TcodUfIbge +from lxml import etree + +# --- Content Bindings --- +from nfelib.mdfe.bindings.v3_0.cons_mdfe_nao_enc_v3_00 import ConsMdfeNaoEnc +from nfelib.mdfe.bindings.v3_0.cons_sit_mdfe_v3_00 import ConsSitMdfe +from nfelib.mdfe.bindings.v3_0.cons_stat_serv_mdfe_v3_00 import ConsStatServMdfe +from nfelib.mdfe.bindings.v3_0.ev_canc_mdfe_v3_00 import ( + EvCancMdfe, + EvCancMdfeDescEvento, +) +from nfelib.mdfe.bindings.v3_0.ev_enc_mdfe_v3_00 import EvEncMdfe, EvEncMdfeDescEvento +from nfelib.mdfe.bindings.v3_0.evento_mdfe_v3_00 import EventoMdfe +from nfelib.mdfe.bindings.v3_0.mdfe_tipos_basico_v3_00 import Tmdfe +from nfelib.mdfe.bindings.v3_0.ret_cons_mdfe_nao_enc_v3_00 import RetConsMdfeNaoEnc +from nfelib.mdfe.bindings.v3_0.ret_cons_sit_mdfe_v3_00 import RetConsSitMdfe +from nfelib.mdfe.bindings.v3_0.ret_cons_stat_serv_mdfe_v3_00 import ( + RetConsStatServMdfe, +) +from nfelib.mdfe.bindings.v3_0.ret_envi_mdfe_v3_00 import RetEnviMdfe +from nfelib.mdfe.bindings.v3_0.ret_evento_mdfe_v3_00 import RetEventoMdfe + +# --- Server Definitions --- +from nfelib.mdfe.client.v3_0.servers import Endpoint +from nfelib.mdfe.client.v3_0.servers import servers as SERVERS_MDFE + +# --- SOAP Bindings --- +from nfelib.mdfe.soap.v3_0.mdfeconsnaoenc import MdfeConsNaoEncSoap12MdfeConsNaoEnc +from nfelib.mdfe.soap.v3_0.mdfeconsulta import MdfeConsultaSoap12MdfeConsultaMdf +from nfelib.mdfe.soap.v3_0.mdferecepcaoevento import ( + MdfeRecepcaoEventoSoap12MdfeRecepcaoEvento, +) +from nfelib.mdfe.soap.v3_0.mdferecepcaosinc import MdfeRecepcaoSincSoap12MdfeRecepcao +from nfelib.mdfe.soap.v3_0.mdfestatusservico import ( + MdfeStatusServicoSoap12MdfeStatusServicoMdf, + MdfeCabecMsg, +) + +_logger = logging.getLogger(__name__) + + +# TODO The event methods in MdfeClient should follow the same high-level pattern +# proposed for NfeClient: accept data primitives (chave, protocolo, etc.) and +# handle the creation and signing of the detEvento internally. + +class MdfeClient(FiscalClient): + """A façade for the MDFe v3.00 SOAP webservices.""" + + def __init__(self, **kwargs: Any): + self.mod = kwargs.pop("mod", "58") + self.soap12_envelope = True + super().__init__(service="mdfe", versao="3.00", **kwargs) + + def _get_location(self, endpoint_type: Endpoint) -> str: + """Constructs the full HTTPS URL for the specified service.""" + server_key = "SVRS" + server_data = SERVERS_MDFE[server_key] + + server_host = ( + server_data["prod_server"] + if self.ambiente == Tamb.PROD.value + else server_data["dev_server"] + ) + path = server_data["endpoints"][endpoint_type] + location = f"https://{server_host}{path}" + _logger.debug(f"Determined location for {endpoint_type.name}: {location}") + return location + + def send( + self, + action_class: type, + obj: Any, + placeholder_exp: Optional[str] = None, + placeholder_content: Optional[str] = None, + **kwargs: Any, + ) -> Any: + """Builds and sends a request, adding the mdfeCabecMsg header if required.""" + action_to_endpoint_map = { + MdfeStatusServicoSoap12MdfeStatusServicoMdf: Endpoint.MDFESTATUSSERVICO, + MdfeConsultaSoap12MdfeConsultaMdf: Endpoint.MDFECONSULTA, + MdfeRecepcaoSincSoap12MdfeRecepcao: Endpoint.MDFERECEPCAOSINC, + MdfeConsNaoEncSoap12MdfeConsNaoEnc: Endpoint.MDFECONSNAOENC, + MdfeRecepcaoEventoSoap12MdfeRecepcaoEvento: Endpoint.MDFERECEPCAOEVENTO, + } + endpoint_type = action_to_endpoint_map[action_class] + location = self._get_location(endpoint_type) + + # Conditionally create the header + header_obj = None + if hasattr(action_class.input, "Header"): + header_obj = action_class.input.Header( + mdfeCabecMsg=MdfeCabecMsg( + cUF=str(self.uf), versaoDados=self.versao + ) + ) + + wrapped_obj = { + "Body": {"mdfeDadosMsg": {"content": [obj]}}, + } + if header_obj: + wrapped_obj["Header"] = header_obj + + response = super().send( + action_class=action_class, + location=location, + wrapped_obj=wrapped_obj, + placeholder_exp=placeholder_exp, + placeholder_content=placeholder_content, + **kwargs, + ) + + if not self.wrap_response: + return response.body.content[0].content[0] + + response.resposta = response.resposta.body.content[0].content[0] + return response + + def status_servico(self) -> RetConsStatServMdfe: + """Consulta o status do serviço MDF-e.""" + payload = ConsStatServMdfe(tpAmb=Tamb(self.ambiente), versao=self.versao) + return self.send(MdfeStatusServicoSoap12MdfeStatusServicoMdf, payload) + + def consulta_documento( + self, chave: str + ) -> RetConsSitMdfe: # NOTE consulta_documento in erpbrasil + """Consulta a situação de um MDF-e pela chave de acesso.""" + payload = ConsSitMdfe( + tpAmb=Tamb(self.ambiente), chMDFe=chave, versao=self.versao + ) + return self.send(MdfeConsultaSoap12MdfeConsultaMdf, payload) + + def consulta_nao_encerrados( + self, cnpj_cpf: str + ) -> RetConsMdfeNaoEnc: # NOTE consulta_nao_encerrados in erpbrasil + """Consulta MDF-es não encerrados para um CNPJ/CPF.""" + payload = ConsMdfeNaoEnc(tpAmb=Tamb(self.ambiente), versao=self.versao) + if len(cnpj_cpf) == 14: + payload.CNPJ = cnpj_cpf + else: + payload.CPF = cnpj_cpf + return self.send(MdfeConsNaoEncSoap12MdfeConsNaoEnc, payload) + + def envia_documento( + self, mdfe_obj: Tmdfe + ) -> RetEnviMdfe: # NOTE envia_documento in erpbrasil + """Envia um lote com um único MDF-e de forma síncrona.""" + signed_xml = mdfe_obj.to_xml( + pkcs12_data=self.pkcs12_data, + pkcs12_password=self.pkcs12_password, + doc_id=mdfe_obj.infMDFe.Id, + ) + # The webservice expects the signed MDFe directly inside mdfeDadosMsg + # This service specifically does not use the mdfeCabecMsg header + return self.send( + MdfeRecepcaoSincSoap12MdfeRecepcao, etree.fromstring(signed_xml) + ) + + def envia_evento( # NOTE envia_evento in erpbrasil + self, + chave: str, + tpEvento: str, + nSeqEvento: str, + detEvento: Any, + cnpj_cpf: str, + dhEvento: Optional[str] = None, + ) -> RetEventoMdfe: + """Envia um evento genérico para um MDF-e.""" + inf_evento = EventoMdfe.InfEvento( + Id=f"ID{tpEvento}{chave}{nSeqEvento.zfill(3)}", + cOrgao=TcodUfIbge(self.uf), + tpAmb=Tamb(self.ambiente), + chMDFe=chave, + dhEvento=dhEvento or self._timestamp(), + tpEvento=tpEvento, + nSeqEvento=nSeqEvento, + detEvento=EventoMdfe.InfEvento.DetEvento( + any_element=detEvento, versaoEvento=self.versao + ), + ) + if len(cnpj_cpf) == 14: + inf_evento.CNPJ = cnpj_cpf + else: + inf_evento.CPF = cnpj_cpf + + evento = EventoMdfe(versao=self.versao, infEvento=inf_evento) + signed_xml = evento.to_xml( + self.pkcs12_data, self.pkcs12_password, evento.infEvento.Id + ) + + return self.send( + MdfeRecepcaoEventoSoap12MdfeRecepcaoEvento, etree.fromstring(signed_xml) + ) + + def cancela_documento( # NOTE cancela_documento in erpbrasil + self, chave: str, protocolo_autorizacao: str, justificativa: str, cnpj_cpf: str + ) -> RetEventoMdfe: + """Cancela um MDF-e autorizado.""" + det_evento = EvCancMdfe( + descEvento=EvCancMdfeDescEvento.CANCELAMENTO, + nProt=protocolo_autorizacao, + xJust=justificativa, + ) + return self.envia_evento( + chave=chave, + tpEvento="110111", + nSeqEvento="1", + detEvento=det_evento, + cnpj_cpf=cnpj_cpf, + ) + + def encerra_documento( # NOTE encerra_documento in erpbrasil + self, + chave: str, + protocolo_autorizacao: str, + estado_ibge: str, + municipio_ibge: str, + cnpj_cpf: str, + ) -> RetEventoMdfe: + """Encerra um MDF-e.""" + det_evento = EvEncMdfe( + descEvento=EvEncMdfeDescEvento.ENCERRAMENTO, + dtEnc=datetime.now().strftime("%Y-%m-%d"), + nProt=protocolo_autorizacao, + cUF=TcodUfIbge(estado_ibge), + cMun=municipio_ibge, + ) + return self.envia_evento( + chave=chave, + tpEvento="110112", + nSeqEvento="1", + detEvento=det_evento, + cnpj_cpf=cnpj_cpf, + ) + + def montar_qrcode( + self, chave: str, signed_xml: str + ) -> str: # NOTE monta_qrcode in erpbrasil + """Monta a URL do QR Code para o DAMDFE.""" + server_data = SERVERS_MDFE["SVRS"] + host = ( + server_data["prod_server"] + if self.ambiente == Tamb.PROD.value + else server_data["dev_server"] + ) + path = server_data["endpoints"][Endpoint.QRCODE] + + base_url = f"https://{host}{path}" + + params = f"?chMDFe={chave}&tpAmb={self.ambiente}" + + xml_tree = etree.fromstring(signed_xml.encode("utf-8")) + tpEmis = xml_tree.find(".//{http://www.portalfiscal.inf.br/mdfe}tpEmis").text + + if tpEmis != "1": + digest_value_b64_element = xml_tree.find( + ".//{http://www.w3.org/2000/09/xmldsig#}DigestValue" + ) + if digest_value_b64_element is not None: + digest_value_b64 = digest_value_b64_element.text + sign = binascii.hexlify(digest_value_b64.encode()).decode() + params += f"&sign={sign}" + + return base_url + params diff --git a/nfelib/mdfe/samples/v3_0/mdfe.xml b/nfelib/mdfe/samples/v3_0/mdfe.xml new file mode 100644 index 00000000..7678c402 --- /dev/null +++ b/nfelib/mdfe/samples/v3_0/mdfe.xml @@ -0,0 +1,69 @@ + + + + 41 + 2 + 1 + 58 + 1 + 10 + 00000010 + 4 + 1 + 2024-02-15T14:30:00-03:00 + 1 + 0 + TestClient 1.0 + PR + SC + + 4106902 + Curitiba + + + + 00000000000100 + 9000000000 + EMPRESA TESTE EMISSAO MDFE + + RUA TESTE + 123 + CENTRO + 4106902 + Curitiba + 80000000 + PR + + + + + + ABC1234 + 6000 + 01 + 02 + PR + + MOTORISTA TESTE + 11111111111 + + + + + + + 4205407 + Florianopolis + + 41240100000000000100550010000000011000000011 + + + + + 1 + 1500.55 + 01 + 12.3456 + + + diff --git a/nfelib/nfe/client/v4_0/dfe.py b/nfelib/nfe/client/v4_0/dfe.py new file mode 100644 index 00000000..c474ce90 --- /dev/null +++ b/nfelib/nfe/client/v4_0/dfe.py @@ -0,0 +1,173 @@ +# Copyright (C) 2019 Luis Felipe Mileo - KMEE +# Copyright (C) 2025 Raphaël Valyi - Akretion + +import logging +from typing import Any, Optional + +from brazil_fiscal_client.fiscal_client import ( + FiscalClient, + Tamb, +) + +# --- Content Bindings --- +from nfelib.nfe.client.v4_0.servers import Endpoint + +# --- Server Definitions --- +from nfelib.nfe.client.v4_0.servers import servers as SERVERS_NFE + +# --- SOAP Bindings --- +from nfelib.nfe.soap.v4_0.nfedistribuicaodfe import ( + NfeDistribuicaoDfeSoapNfeDistDfeInteresse, +) + +# --- Dist DF-e --- +from nfelib.nfe_dist_dfe.bindings.v1_0 import DistDfeInt, RetDistDfeInt + +_logger = logging.getLogger(__name__) + + +class DfeClient(FiscalClient): + """A façade for the NFe SOAP webservices.""" + + def __init__(self, **kwargs: Any): + self.mod = kwargs.pop("mod", "55") + super().__init__( + service="nfe", + versao="1.01", + **kwargs, + ) + + def _get_location(self, endpoint_type: Endpoint) -> str: + """Construct the full HTTPS URL for the specified service.""" + server_key = "AN" + try: + server_data = SERVERS_NFE[server_key] + except KeyError: + raise ValueError( + f"No server configuration found for key: {server_key} " + "(derived from UF {self.uf})" + ) + + if self.ambiente == Tamb.PROD.value: + server_host = server_data["prod_server"] + else: + server_host = server_data["dev_server"] + + try: + path = server_data["endpoints"][endpoint_type] + except KeyError: + raise ValueError( + f"Endpoint {endpoint_type.name} not configured for server key: " + "{server_key}" + ) + + location = f"https://{server_host}{path}" + _logger.debug( + f"Determined location for {endpoint_type.name} (UF: {self.uf}, " + "Amb: {self.ambiente}): {location}" + ) + return location + + def send( + self, + action_class: type, + obj: Any, + placeholder_exp: Optional[str] = None, + placeholder_content: Optional[str] = None, + **kwargs: Any, + ) -> Any: + """Build and send a request for the input object. + + Args: + action_class: type generated with xsdata for the SOAP wsdl action + (e.g., NfeStatusServico4SoapNfeStatusServicoNf). + obj: The *content* model instance (e.g., ConsStatServ) or a dictionary. + This will be wrapped inside nfeDadosMsg. + placeholder_content: A string content to be injected in the payload. + Used for signed content to avoid signature issues. + placeholder_exp: Placeholder expression where to inject placeholder_content. + kwargs: Additional keyword arguments for FiscalClient.send. + + Returns: + The *content* response model instance (e.g., RetConsStatServ). + """ + try: + # Determine the correct endpoint enum based on the action class + action_to_endpoint_map: dict[type, Endpoint] = { + NfeDistribuicaoDfeSoapNfeDistDfeInteresse: Endpoint.NFEDISTRIBUICAODFE, + } + endpoint_type = action_to_endpoint_map[action_class] + location = self._get_location(endpoint_type) + + except KeyError: + raise ValueError( + "Could not determine Endpoint for action_class: {action_class.__name__}" + ) + if isinstance(obj, DistDfeInt): + wrapped_obj = { + "Body": {"nfeDistDFeInteresse": {"nfeDadosMsg": {"content": [obj]}}} + } + else: + wrapped_obj = {"Body": {"nfeDadosMsg": {"content": [obj]}}} + + response = super().send( + action_class, + location, + wrapped_obj, + placeholder_exp=placeholder_exp, + placeholder_content=placeholder_content, + **kwargs, + ) + if not self.wrap_response: + return response.body.nfeDistDFeInteresseResponse.nfeDistDFeInteresseResult.content[ + 0 + ] + response.resposta = response.resposta.body.nfeDistDFeInteresseResponse.nfeDistDFeInteresseResult.content[ + 0 + ] + return response + + def consultar_distribuicao( + self, + cnpj_cpf: str, + ultimo_nsu: str = "", + nsu_especifico: str = "", + chave: str = "", + ) -> Optional[RetDistDfeInt]: + """Consultar Disitbução de NFe. + :param cnpj_cpf: CPF ou CNPJ a ser consultado + :param ultimo_nsu: Último NSU para pesquisa. Formato: '999999999999999' + :param nsu_especifico: NSU Específico para pesquisa. + Formato: '999999999999999' + :param chave: Chave de acesso do documento + :return: Retorna uma estrutura contendo as estruturas de envio + e retorno preenchidas + """ + if not ultimo_nsu and not nsu_especifico and not chave: + return None + + distNSU = consNSU = consChNFe = None + if ultimo_nsu: + distNSU = DistDfeInt.DistNsu(ultNSU=ultimo_nsu) + if nsu_especifico: + consNSU = DistDfeInt.ConsNsu(NSU=nsu_especifico) + if chave: + consChNFe = DistDfeInt.ConsChNfe(chNFe=chave) + + if (distNSU and consNSU) or (distNSU and consChNFe) or (consNSU and consChNFe): + # TODO: Raise? + return None + + return self.send( + NfeDistribuicaoDfeSoapNfeDistDfeInteresse, + DistDfeInt( + versao=self.versao, + tpAmb=self.ambiente, + cUFAutor=self.uf, + CNPJ=cnpj_cpf if len(cnpj_cpf) > 11 else None, + CPF=cnpj_cpf if len(cnpj_cpf) <= 11 else None, + distNSU=distNSU, + consNSU=consNSU, + consChNFe=consChNFe, + ), + ) diff --git a/nfelib/nfe/client/v4_0/mde.py b/nfelib/nfe/client/v4_0/mde.py new file mode 100644 index 00000000..c7be11a3 --- /dev/null +++ b/nfelib/nfe/client/v4_0/mde.py @@ -0,0 +1,241 @@ +# FILEPATH: nfelib/nfe/client/v4_0/mde.py +# Copyright (C) 2020 - KMEE +# Copyright (C) 2025 Raphaël Valyi - Akretion + +import logging +from datetime import datetime +from typing import Any, Optional + +from brazil_fiscal_client.fiscal_client import FiscalClient, Tamb + +# --- Server Definitions & SOAP Bindings --- +from nfelib.nfe.client.v4_0.servers import Endpoint +from nfelib.nfe.client.v4_0.servers import servers as SERVERS_NFE +from nfelib.nfe.soap.v4_0.recepcaoevento4 import ( + NfeRecepcaoEvento4SoapNfeRecepcaoEvento, +) + +# --- MD-e Event Bindings --- +from nfelib.nfe_evento_mde.bindings.v1_0.leiaute_conf_recebto_v1_00 import ( + DetEventoDescEvento, + InfEventoTpEvento, + TcorgaoIbge, + TenvEvento, + Tevento, + TretEnvEvento, +) + +_logger = logging.getLogger(__name__) + +# TODO Flaw/Inconsistency: Method names like confirmacao_da_operacao are +# not Pythonic. The use of _ separates words but the name itself is a noun phrase, +# not a verb. +# TODO confirmacao_da_operacao -> confirmar_operacao; +# ciencia_da_operacao -> registrar_ciencia; +# desconhecimento_da_operacao -> registrar_desconhecimento; +# operacao_nao_realizada -> registrar_operacao_nao_realizada +# (and require justificativa as a non-optional argument). + + +class MdeClient(FiscalClient): + """A façade for the NFe Manifestação do Destinatário (MD-e) SOAP webservices.""" + + def __init__(self, **kwargs: Any): + # The user provides their own UF, but for endpoint resolution, MD-e always uses Ambiente Nacional (AN). + # We will override _get_location to enforce this. + super().__init__( + service="nfe", + versao="1.00", # MD-e events have their own versioning, typically 1.00 + **kwargs, + ) + + def _get_location(self, endpoint_type: Endpoint) -> str: + """Overrides the parent method to always use the Ambiente Nacional (AN) + server for MD-e event reception, regardless of the client's configured UF. + """ + if endpoint_type != Endpoint.RECEPCAOEVENTO: + raise ValueError( + f"Endpoint {endpoint_type.name} is not supported for MDeClient. " + "Only RecepcaoEvento is allowed." + ) + + server_key = "AN" # Always use Ambiente Nacional + server_data = SERVERS_NFE[server_key] + server_host = ( + server_data["prod_server"] + if self.ambiente == Tamb.PROD.value + else server_data["dev_server"] + ) + path = server_data["endpoints"][endpoint_type] + location = f"https://{server_host}{path}" + _logger.debug( + f"Determined MD-e location for {endpoint_type.name} " + f"(Amb: {self.ambiente}): {location}" + ) + return location + + def send( + self, + action_class: type, + obj: Any, + placeholder_exp: Optional[str] = None, + placeholder_content: Optional[str] = None, + **kwargs: Any, + ) -> Any: + """Builds and sends a request for the input object, correctly wrapping it.""" + endpoint_type = Endpoint.RECEPCAOEVENTO + location = self._get_location(endpoint_type) + + wrapped_obj = {"Body": {"nfeDadosMsg": {"content": [obj]}}} + + response = super().send( + action_class=action_class, + location=location, + wrapped_obj=wrapped_obj, + placeholder_exp=placeholder_exp, + placeholder_content=placeholder_content, + **kwargs, + ) + + if not self.wrap_response: + return response.body.nfeResultMsg.content[0] + + # Adapt the wrapped response to contain the direct result + response.resposta = response.resposta.body.nfeResultMsg.content[0] + return response + + def nfe_recepcao_envia_lote_evento( + self, lista_eventos: list[Tevento], numero_lote: Optional[str] = None + ) -> TretEnvEvento: + """Signs and sends a batch of manifestation events. + + :param lista_eventos: A list of fully formed Tevento objects to be sent. + :param numero_lote: Optional batch number. If not provided, one is generated. + :return: The processing result from the SEFAZ. + """ + if not numero_lote: + numero_lote = str(int(datetime.now().timestamp() * 1000))[-15:] + + signed_events_xml = [] + for evento in lista_eventos: + signed_xml = evento.to_xml( + pkcs12_data=self.pkcs12_data, + pkcs12_password=self.pkcs12_password, + doc_id=evento.infEvento.Id, + ) + signed_events_xml.append(signed_xml) + + env_evento_payload = TenvEvento( + versao="1.00", + idLote=numero_lote, + evento=[Tevento()], # Placeholder for replacement + ) + + # The placeholder should match the entire tag block + placeholder_exp = r".*?" + + return self.send( + NfeRecepcaoEvento4SoapNfeRecepcaoEvento, + env_evento_payload, + placeholder_exp=placeholder_exp, + placeholder_content="".join(signed_events_xml), + ) + + def _monta_evento( + self, + chave: str, + cnpj_cpf: str, + tpEvento: InfEventoTpEvento, + descEvento: DetEventoDescEvento, + nSeqEvento: str = "1", + xJust: Optional[str] = None, + ) -> Tevento: + """Helper method to create the full Tevento structure for a manifestation event.""" + if not (isinstance(chave, str) and len(chave) == 44 and chave.isdigit()): + raise ValueError(f"Chave de acesso inválida: {chave}") + if not (1 <= int(nSeqEvento) <= 20): + raise ValueError("Sequência do evento inválida (1-20).") + + doc_destinatario = {} + if len(cnpj_cpf) == 14 and cnpj_cpf.isdigit(): + doc_destinatario = {"CNPJ": cnpj_cpf, "CPF": None} + elif len(cnpj_cpf) == 11 and cnpj_cpf.isdigit(): + doc_destinatario = {"CNPJ": None, "CPF": cnpj_cpf} + else: + raise ValueError(f"CNPJ/CPF do destinatário inválido: {cnpj_cpf}") + + inf_evento = Tevento.InfEvento( + Id="ID" + tpEvento.value + chave + nSeqEvento.zfill(2), + cOrgao=TcorgaoIbge.VALUE_91, + tpAmb=Tamb(self.ambiente), + CNPJ=doc_destinatario["CNPJ"], + CPF=doc_destinatario["CPF"], + chNFe=chave, + dhEvento=self._timestamp(), + tpEvento=tpEvento, + nSeqEvento=nSeqEvento, + verEvento="1.00", + detEvento=Tevento.InfEvento.DetEvento( + versao="1.00", descEvento=descEvento, xJust=xJust + ), + ) + return Tevento(versao="1.00", infEvento=inf_evento) + + def _enviar_evento_unitario( + self, + chave: str, + cnpj_cpf: str, + tpEvento: InfEventoTpEvento, + descEvento: DetEventoDescEvento, + xJust: Optional[str] = None, + ) -> TretEnvEvento: + """Creates and sends a single manifestation event.""" + evento = self._monta_evento( + chave=chave, + cnpj_cpf=cnpj_cpf, + tpEvento=tpEvento, + descEvento=descEvento, + xJust=xJust, + ) + return self.nfe_recepcao_envia_lote_evento( + lista_eventos=[evento], numero_lote="1" + ) + + def confirmacao_da_operacao(self, chave: str, cnpj_cpf: str) -> TretEnvEvento: + return self._enviar_evento_unitario( + chave=chave, + cnpj_cpf=cnpj_cpf, + tpEvento=InfEventoTpEvento.VALUE_210200, + descEvento=DetEventoDescEvento.CONFIRMACAO_DA_OPERACAO, + ) + + def ciencia_da_operacao(self, chave: str, cnpj_cpf: str) -> TretEnvEvento: + return self._enviar_evento_unitario( + chave=chave, + cnpj_cpf=cnpj_cpf, + tpEvento=InfEventoTpEvento.VALUE_210210, + descEvento=DetEventoDescEvento.CIENCIA_DA_OPERACAO, + ) + + def desconhecimento_da_operacao(self, chave: str, cnpj_cpf: str) -> TretEnvEvento: + return self._enviar_evento_unitario( + chave=chave, + cnpj_cpf=cnpj_cpf, + tpEvento=InfEventoTpEvento.VALUE_210220, + descEvento=DetEventoDescEvento.DESCONHECIMENTO_DA_OPERACAO, + ) + + def operacao_nao_realizada( + self, chave: str, cnpj_cpf: str, justificativa: str + ) -> TretEnvEvento: + if not (15 <= len(justificativa) <= 255): + raise ValueError( + "Justificativa para 'Operação não Realizada' deve ter entre 15 e 255 caracteres." + ) + return self._enviar_evento_unitario( + chave=chave, + cnpj_cpf=cnpj_cpf, + tpEvento=InfEventoTpEvento.VALUE_210240, + descEvento=DetEventoDescEvento.OPERACAO_NAO_REALIZADA, + xJust=justificativa, + ) diff --git a/nfelib/nfe/client/v4_0/nfce.py b/nfelib/nfe/client/v4_0/nfce.py new file mode 100644 index 00000000..7cfbdbd0 --- /dev/null +++ b/nfelib/nfe/client/v4_0/nfce.py @@ -0,0 +1,208 @@ +# FILEPATH: nfelib/nfe/client/v4_0/nfce.py +# Copyright (C) 2023 Ygor de Carvalho - KMEE +# Copyright (C) 2025 Raphaël Valyi - Akretion + +import binascii +import hashlib +import logging +from typing import List, Optional + +from lxml import etree + +from nfelib.nfe.bindings.v4_0.leiaute_nfe_v4_00 import TenviNfeIndSinc, Tnfe +from nfelib.nfe.bindings.v4_0.ret_envi_nfe_v4_00 import RetEnviNfe +from nfelib.nfe.client.v4_0.nfe import NfeClient +from nfelib.nfe.client.v4_0.servers_nfce import ( + ESTADO_CONSULTA_NFCE, + ESTADO_QRCODE, +) + +_logger = logging.getLogger(__name__) + +# --- Constants --- +NAMESPACES = { + "nfe": "http://www.portalfiscal.inf.br/nfe", + "ds": "http://www.w3.org/2000/09/xmldsig#", +} + +# TODO Migration Impact: Medium. Every call to envia_documento for NFC-e will +# now need to include the CSC credentials. This requires code changes but makes the +# system more robust for multi-establishment scenarios. +# Move csc_token and csc_code from __init__ to the envia_documento method +# signature. This makes the client stateless regarding CSCs and more versatile. + +class NfceClient(NfeClient): + """A façade for the NFC-e SOAP webservices, extending the NFe client.""" + + def __init__( + self, + qrcode_versao: str = "2", + csc_token: Optional[str] = None, + csc_code: Optional[str] = None, + **kwargs, + ): + # ... (init method remains the same) ... + kwargs["mod"] = "65" + super().__init__(**kwargs) + self.envio_sincrono = True + + if not csc_token or not csc_code: + raise ValueError("csc_token and csc_code are required for NfceClient") + + self.qrcode_versao = str(qrcode_versao) + self.csc_token = str(csc_token) + self.csc_code = str(csc_code) + + # ... (_build_pre_qrcode_normal, _compute_qr_hash, _build_qrcode, monta_qrcode, _generate_qrcode_contingency remain the same) ... + + def _build_pre_qrcode_normal(self, nfce_chave: str) -> str: + return f"{nfce_chave}|{self.qrcode_versao}|{self.ambiente}|{self.csc_token}" + + def _compute_qr_hash(self, pre_qrcode_with_csc: str) -> str: + hash_object = hashlib.sha1(pre_qrcode_with_csc.encode("utf-8")) + return hash_object.hexdigest().upper() + + def _build_qrcode(self, pre_qrcode_without_csc: str, qr_hash: str) -> str: + uf_sigla = self.uf_code_to_sigla(self.uf) + base_url = ESTADO_QRCODE.get(uf_sigla, {}).get(self.ambiente) + if not base_url: + raise ValueError( + f"URL de QR Code não encontrada para UF {uf_sigla} no ambiente {self.ambiente}" + ) + return f"{base_url}{pre_qrcode_without_csc}|{qr_hash}" + + def monta_qrcode(self, chave: str) -> str: + pre_qrcode_normal = self._build_pre_qrcode_normal(chave) + pre_qrcode_with_csc = f"{pre_qrcode_normal}{self.csc_code}" + qr_hash = self._compute_qr_hash(pre_qrcode_with_csc) + return self._build_qrcode(pre_qrcode_normal, qr_hash) + + def _generate_qrcode_contingency(self, edoc_obj: Tnfe, signed_xml: str) -> str: + xml_tree = etree.fromstring(signed_xml.encode("utf-8")) + chave_nfce = edoc_obj.infNFe.Id.replace("NFe", "") + data_emissao_dia = edoc_obj.infNFe.ide.dhEmi[8:10] + total_nfe = xml_tree.find(".//nfe:ICMSTot/nfe:vNF", namespaces=NAMESPACES).text + digest_value_b64 = xml_tree.find( + ".//ds:DigestValue", namespaces=NAMESPACES + ).text + digest_value_hex = binascii.hexlify(digest_value_b64.encode()).decode() + pre_qrcode_contingency = ( + f"{chave_nfce}|{self.qrcode_versao}|{self.ambiente}|{data_emissao_dia}" + f"|{total_nfe}|{digest_value_hex}|{self.csc_token}" + ) + pre_qrcode_with_csc = f"{pre_qrcode_contingency}{self.csc_code}" + qr_hash = self._compute_qr_hash(pre_qrcode_with_csc) + return self._build_qrcode(pre_qrcode_contingency, qr_hash) + + def _update_qrcode_nfce( + self, edoc_obj: Tnfe, signed_xml: Optional[str] = None + ) -> None: + is_contingency = edoc_obj.infNFe.ide.tpEmis != "1" + chave = edoc_obj.infNFe.Id.replace("NFe", "") + + if is_contingency: + if not signed_xml: + raise ValueError( + "Signed XML is required for contingency QR Code generation." + ) + qr_code_text = self._generate_qrcode_contingency(edoc_obj, signed_xml) + else: + qr_code_text = self.monta_qrcode(chave) + + if not edoc_obj.infNFeSupl: + edoc_obj.infNFeSupl = Tnfe.InfNfeSupl() + edoc_obj.infNFeSupl.qrCode = qr_code_text + + def envia_documento( + self, lista_nfes: List[Tnfe], id_lote: Optional[str] = None + ) -> RetEnviNfe: + if len(lista_nfes) != 1: + raise ValueError( + "NfceClient.envia_documento supports only one NFC-e at a time." + ) + + edoc_obj = lista_nfes[0] + is_contingency = edoc_obj.infNFe.ide.tpEmis != "1" + + if is_contingency: + # 1. Sign once to get the digest for the QR code + initial_signed_xml = edoc_obj.to_xml( + pkcs12_data=self.pkcs12_data, + pkcs12_password=self.pkcs12_password, + doc_id=edoc_obj.infNFe.Id, + ) + # 2. Update the QR code using the digest from the signed XML + self._update_qrcode_nfce(edoc_obj, initial_signed_xml) + # 3. Re-sign the object now that it contains the final QR code + _logger.info( + "Contingency emission detected. Re-signing NFe with updated QR Code." + ) + final_signed_xml = edoc_obj.to_xml( + pkcs12_data=self.pkcs12_data, + pkcs12_password=self.pkcs12_password, + doc_id=edoc_obj.infNFe.Id, + ) + else: # Normal Emission + # 1. Update the QR code (doesn't need digest) + self._update_qrcode_nfce(edoc_obj, None) + # 2. Sign the final object once + final_signed_xml = edoc_obj.to_xml( + pkcs12_data=self.pkcs12_data, + pkcs12_password=self.pkcs12_password, + doc_id=edoc_obj.infNFe.Id, + ) + + return super().envia_documento( + lista_nfes=[final_signed_xml], + id_lote=id_lote, + ind_sinc=TenviNfeIndSinc.VALUE_1, + ) + + def consulta_recibo(self, proc_envio: RetEnviNfe) -> RetEnviNfe: + _logger.info("NFC-e is synchronous; returning original send result.") + return proc_envio + + def get_consulta_url(self) -> str: + uf_sigla = self.uf_code_to_sigla(self.uf) + url = ESTADO_CONSULTA_NFCE.get(uf_sigla, {})[int(self.ambiente) - 1] + if not url: + raise ValueError( + f"URL de Consulta não encontrada para UF {uf_sigla} no ambiente {self.ambiente}" + ) + return url + + @staticmethod + def uf_code_to_sigla(uf_code: str) -> str: + uf_map = { + "12": "AC", + "27": "AL", + "16": "AP", + "13": "AM", + "29": "BA", + "23": "CE", + "53": "DF", + "32": "ES", + "52": "GO", + "21": "MA", + "51": "MT", + "50": "MS", + "31": "MG", + "15": "PA", + "25": "PB", + "41": "PR", + "26": "PE", + "22": "PI", + "33": "RJ", + "24": "RN", + "43": "RS", + "11": "RO", + "14": "RR", + "42": "SC", + "35": "SP", + "28": "SE", + "17": "TO", + } + sigla = uf_map.get(str(uf_code)) + if not sigla: + raise ValueError(f"Invalid UF code: {uf_code}") + return sigla diff --git a/nfelib/nfe/client/v4_0/nfe.py b/nfelib/nfe/client/v4_0/nfe.py new file mode 100644 index 00000000..e1653a8a --- /dev/null +++ b/nfelib/nfe/client/v4_0/nfe.py @@ -0,0 +1,887 @@ +# Copyright (C) 2019 Luis Felipe Mileo - KMEE +# Copyright (C) 2025 Raphaël Valyi - Akretion + +import logging +import time +from datetime import date, datetime +from typing import Any, Optional + +from brazil_fiscal_client.fiscal_client import ( + FiscalClient, + Tamb, + TcodUfIbge, + WrappedResponse, +) +from lxml import etree + +from nfelib import CommonMixin +from nfelib.nfe.bindings.v4_0.cons_reci_nfe_v4_00 import ConsReciNfe +from nfelib.nfe.bindings.v4_0.cons_sit_nfe_v4_00 import ConsSitNfe +from nfelib.nfe.bindings.v4_0.cons_stat_serv_v4_00 import ConsStatServ +from nfelib.nfe.bindings.v4_0.envi_nfe_v4_00 import EnviNfe +from nfelib.nfe.bindings.v4_0.inut_nfe_v4_00 import InutNfe +from nfelib.nfe.bindings.v4_0.leiaute_cons_sit_nfe_v4_00 import ( + TconsSitNfeXServ, + TprotNfe, +) +from nfelib.nfe.bindings.v4_0.leiaute_cons_stat_serv_v4_00 import TconsStatServXServ +from nfelib.nfe.bindings.v4_0.leiaute_inut_nfe_v4_00 import InfInutXServ +from nfelib.nfe.bindings.v4_0.leiaute_nfe_v4_00 import TenviNfeIndSinc, Tnfe + +# --- Content Bindings --- +from nfelib.nfe.bindings.v4_0.proc_nfe_v4_00 import NfeProc +from nfelib.nfe.bindings.v4_0.ret_cons_reci_nfe_v4_00 import RetConsReciNfe +from nfelib.nfe.bindings.v4_0.ret_cons_sit_nfe_v4_00 import RetConsSitNfe +from nfelib.nfe.bindings.v4_0.ret_cons_stat_serv_v4_00 import RetConsStatServ +from nfelib.nfe.bindings.v4_0.ret_envi_nfe_v4_00 import RetEnviNfe +from nfelib.nfe.bindings.v4_0.ret_inut_nfe_v4_00 import RetInutNfe + +# Corrected import for InutNfe.InfInut +from nfelib.nfe.bindings.v4_0.tipos_basico_v4_00 import Tmod +from nfelib.nfe.client.v4_0.servers import Endpoint + +# --- Server Definitions --- +# Import Endpoint Enum and the servers dictionary directly +from nfelib.nfe.client.v4_0.servers import servers as SERVERS_NFE + +# --- SOAP Bindings --- +from nfelib.nfe.soap.v4_0.nfeautorizacao4 import ( + NfeAutorizacao4SoapNfeAutorizacaoLote, +) +from nfelib.nfe.soap.v4_0.nfeconsulta4 import ( + NfeConsultaProtocolo4SoapNfeConsultaNf, +) +from nfelib.nfe.soap.v4_0.nfeinutilizacao4 import ( + NfeInutilizacao4SoapNfeInutilizacaoNf, +) +from nfelib.nfe.soap.v4_0.nferetautorizacao4 import ( + NfeRetAutorizacao4SoapNfeRetAutorizacaoLote, +) +from nfelib.nfe.soap.v4_0.nfestatusservico4 import ( + NfeStatusServico4SoapNfeStatusServicoNf, +) +from nfelib.nfe.soap.v4_0.recepcaoevento4 import ( + NfeRecepcaoEvento4SoapNfeRecepcaoEvento, +) + +# --- Dist DF-e --- +from nfelib.nfe_evento_cancel.bindings.v1_0.e110111_v1_00 import ( + DetEventoDescEvento as DetEventoDescEventoCancel, +) +from nfelib.nfe_evento_cancel.bindings.v1_0.e110111_v1_00 import ( + DetEventoVersao as DetEventoVersaoCancel, +) + +# --- Event Bindings --- +from nfelib.nfe_evento_cancel.bindings.v1_0.evento_canc_nfe_v1_00 import ( + Evento as TeventoCancel, +) +from nfelib.nfe_evento_cancel.bindings.v1_0.leiaute_evento_canc_nfe_v1_00 import ( + InfEventoTpEvento as InfEventoTpEventoCancel, +) +from nfelib.nfe_evento_cancel.bindings.v1_0.leiaute_evento_canc_nfe_v1_00 import ( + InfEventoVerEvento as InfEventoVerEventoCancel, +) +from nfelib.nfe_evento_cancel.bindings.v1_0.leiaute_evento_canc_nfe_v1_00 import ( + TretEnvEvento, +) +from nfelib.nfe_evento_cce.bindings.v1_0.leiaute_cce_v1_00 import ( + DetEventoDescEvento as DetEventoDescEventoCCe, +) +from nfelib.nfe_evento_cce.bindings.v1_0.leiaute_cce_v1_00 import ( + DetEventoVersao as DetEventoVersaoCCe, +) +from nfelib.nfe_evento_cce.bindings.v1_0.leiaute_cce_v1_00 import ( + DetEventoXCondUso, +) +from nfelib.nfe_evento_cce.bindings.v1_0.leiaute_cce_v1_00 import ( + InfEventoTpEvento as InfEventoTpEventoCCe, +) +from nfelib.nfe_evento_cce.bindings.v1_0.leiaute_cce_v1_00 import ( + InfEventoVerEvento as InfEventoVerEventoCCe, +) +from nfelib.nfe_evento_cce.bindings.v1_0.leiaute_cce_v1_00 import ( + Tevento as TeventoCCe, +) + +# --- Constants --- +TIPO_EVENTO_CCE = "110110" +TIPO_EVENTO_CANCEL = "110111" + +_logger = logging.getLogger(__name__) + + +# --- UF to Server Key Mapping & Logic (based on SEFAZ page) --- + +# 1. Define sets for easier checking based on SEFAZ rules +_SVRS_CONSULTACADASTRO_UFS = {"12", "32", "24", "25", "42"} # AC, ES, RN, PB, SC +_SVRS_NORMAL_UFS = { + "12", + "27", + "16", + "23", + "53", + "32", + "15", + "25", + "22", + "33", + "24", + "11", + "14", + "42", + "28", + "17", +} # AC, AL, AP, CE, DF, ES, PA, PB, PI, RJ, RN, RO, RR, SC, SE, TO +_SVAN_NORMAL_UFS = {"21"} # MA + +# UFs with their own dedicated servers (not using SVRS/SVAN normally) +_OWN_SERVER_UFS = { + "13": "AM", # Amazonas + "29": "BA", # Bahia + "52": "GO", # Goiás + "31": "MG", # Minas Gerais + "50": "MS", # Mato Grosso do Sul + "51": "MT", # Mato Grosso do Sul + "26": "PE", # Pernambuco + "41": "PR", # Paraná <= Uses SVRS now based on the page + "43": "RS", # Rio Grande do Sul + "35": "SP", # São Paulo +} + +_SVAN_NORMAL_UFS = {"21", "15"} # MA, PA +_SVRS_NORMAL_UFS = { # Re-list SVRS UFs excluding MA, PA + "12", + "27", + "16", + "23", + "53", + "32", + "25", + "22", + "33", + "24", + "11", + "14", + "42", + "28", + "17", +} # AC, AL, AP, CE, DF, ES, PB, PI, RJ, RN, RO, RR, SC, SE, TO +_OWN_SERVER_UFS = { # Re-list Own Server UFs excluding CE + "13": "AM", # Amazonas + "29": "BA", # Bahia + "52": "GO", # Goiás + "31": "MG", # Minas Gerais + "50": "MS", # Mato Grosso do Sul + "51": "MT", # Mato Grosso do Sul + "26": "PE", # Pernambuco + "41": "PR", # Paraná + "43": "RS", # Rio Grande do Sul + "35": "SP", # São Paulo +} + + +def _get_server_key_for_uf(uf_ibge_code: str, endpoint: Endpoint) -> str: + """Get the server key ('AM', 'SVRS', 'AN', etc.) for a given UF and Endpoint.""" + # 1. Check ConsultaCadastro special rule + if ( + endpoint == Endpoint.NFECONSULTACADASTRO + and uf_ibge_code in _SVRS_CONSULTACADASTRO_UFS + ): + _logger.debug(f"Using SVRS for ConsultaCadastro for UF {uf_ibge_code}") + return "SVRS" + + # 2. Check UFs with their own servers + if uf_ibge_code in _OWN_SERVER_UFS: + return _OWN_SERVER_UFS[uf_ibge_code] + + # 3. Check UFs using SVAN + if uf_ibge_code in _SVAN_NORMAL_UFS: + return "SVAN" + + # 4. Check UFs using SVRS (default for most others listed) + if uf_ibge_code in _SVRS_NORMAL_UFS: + return "SVRS" + + # 5. Fallback/Error Handling - Should not happen if all UFs are mapped + _logger.error( + f"Could not determine server key for UF {uf_ibge_code} and endpoint " + "{endpoint.name}. Check mappings." + ) + # Defaulting to SVRS might hide configuration issues, raising an error is safer. + raise ValueError(f"Server mapping not found for UF {uf_ibge_code}") + + +# TODO methods -> verbs +# TODO enviar_documento -> envior_lote + + +class NfeClient(FiscalClient): + """A façade for the NFe SOAP webservices.""" + + def __init__(self, **kwargs: Any): + self.mod = kwargs.pop("mod", "55") + self.envio_sincrono = kwargs.pop("envio_sincrono", False) + super().__init__( + service="nfe", + versao="4.00", + **kwargs, + ) + + def _get_location(self, endpoint_type: Endpoint) -> str: + """Construct the full HTTPS URL for the specified service.""" + server_key = _get_server_key_for_uf( + self.uf, endpoint_type + ) # TODO self.envio_sincrono + try: + server_data = SERVERS_NFE[server_key] + except KeyError: + raise ValueError( + f"No server configuration found for key: {server_key} " + "(derived from UF {self.uf})" + ) + + if self.ambiente == Tamb.PROD.value: + server_host = server_data["prod_server"] + else: + server_host = server_data["dev_server"] + + try: + path = server_data["endpoints"][endpoint_type] + except KeyError: + raise ValueError( + f"Endpoint {endpoint_type.name} not configured for server key: " + "{server_key}" + ) + + location = f"https://{server_host}{path}" + _logger.debug( + f"Determined location for {endpoint_type.name} (UF: {self.uf}, " + "Amb: {self.ambiente}): {location}" + ) + return location + + def send( + self, + action_class: type, + obj: Any, + placeholder_exp: Optional[str] = None, + placeholder_content: Optional[str] = None, + **kwargs: Any, + ) -> Any: + """Build and send a request for the input object. + + Args: + action_class: type generated with xsdata for the SOAP wsdl action + (e.g., NfeStatusServico4SoapNfeStatusServicoNf). + obj: The *content* model instance (e.g., ConsStatServ) or a dictionary. + This will be wrapped inside nfeDadosMsg. + placeholder_content: A string content to be injected in the payload. + Used for signed content to avoid signature issues. + placeholder_exp: Placeholder expression where to inject placeholder_content. + kwargs: Additional keyword arguments for FiscalClient.send. + + Returns: + The *content* response model instance (e.g., RetConsStatServ). + """ + try: + # Determine the correct endpoint enum based on the action class + action_to_endpoint_map: dict[type, Endpoint] = { + NfeStatusServico4SoapNfeStatusServicoNf: Endpoint.NFESTATUSSERVICO, + NfeConsultaProtocolo4SoapNfeConsultaNf: Endpoint.NFECONSULTAPROTOCOLO, + NfeAutorizacao4SoapNfeAutorizacaoLote: Endpoint.NFEAUTORIZACAO, + NfeRetAutorizacao4SoapNfeRetAutorizacaoLote: Endpoint.NFERETAUTORIZACAO, + NfeInutilizacao4SoapNfeInutilizacaoNf: Endpoint.NFEINUTILIZACAO, + NfeRecepcaoEvento4SoapNfeRecepcaoEvento: Endpoint.RECEPCAOEVENTO, + } + endpoint_type = action_to_endpoint_map[action_class] + location = self._get_location(endpoint_type) + + except KeyError: + raise ValueError( + "Could not determine Endpoint for action_class: {action_class.__name__}" + ) + wrapped_obj = {"Body": {"nfeDadosMsg": {"content": [obj]}}} + + response = super().send( + action_class, + location, + wrapped_obj, + placeholder_exp=placeholder_exp, + placeholder_content=placeholder_content, + **kwargs, + ) + if not self.wrap_response: + return response.body.nfeResultMsg.content[0] + response.resposta = response.resposta.body.nfeResultMsg.content[0] + return response + + ###################################### + # Webservices + ###################################### + + def status_servico(self) -> Optional[RetConsStatServ]: + """Consulta o status do serviço NFe.""" + payload = ConsStatServ( + tpAmb=Tamb(self.ambiente), + cUF=TcodUfIbge(self.uf), + xServ=TconsStatServXServ.STATUS, + versao=self.versao, + ) + return self.send(NfeStatusServico4SoapNfeStatusServicoNf, payload) + + def consulta_documento(self, chave: str) -> Optional[RetConsSitNfe]: + """Consulta a situação de uma NF-e pela chave de acesso.""" + if not (isinstance(chave, str) and len(chave) == 44 and chave.isdigit()): + raise ValueError(f"Chave de acesso inválida: {chave}") + + payload = ConsSitNfe( + versao=self.versao, + tpAmb=Tamb(self.ambiente), + xServ=TconsSitNfeXServ.CONSULTAR, + chNFe=chave, + ) + return self.send(NfeConsultaProtocolo4SoapNfeConsultaNf, payload) + + # NOTE: I changed the signature from erpbrasil.edoc to support a list of NFe's + def envia_documento( + self, + lista_nfes: list, + id_lote: str = "", + ind_sinc: TenviNfeIndSinc = TenviNfeIndSinc.VALUE_1, + ) -> RetEnviNfe: + """Autoriza uma lista de NFe's.""" + signed_nfes = [] + for nfe in lista_nfes: + if isinstance(nfe, str): + if "X509Certificate" in nfe: + signed_nfes.append(nfe) + else: + nfe_etree = etree.fromstring(nfe.encode("utf-8")) + ns = {"nfe": "http://www.portalfiscal.inf.br/nfe"} + doc_id = nfe_etree.xpath("//nfe:infNFe/@Id", namespaces=ns)[0] + signed_nfes.append( + CommonMixin.sign_xml( + xml=nfe, + pkcs12_data=self.pkcs12_data, + pkcs12_password=self.pkcs12_password, + doc_id=doc_id, + ) + ) + else: + signed_nfes.append( + nfe.to_xml( + pkcs12_data=self.pkcs12_data, + pkcs12_password=self.pkcs12_password, + doc_id=nfe.infNFe.Id, + ) + ) + + return self.send( + NfeAutorizacao4SoapNfeAutorizacaoLote, + EnviNfe( + idLote=id_lote or datetime.now().strftime("%Y%m%d%H%M%S"), + versao=self.versao, + indSinc=ind_sinc, + # we pass an empty placeholder_exp for the NFe to avoid an extra + # XML parsing/serialization and possibly screwing the signature. + NFe=[Tnfe()], + ), + placeholder_exp="", + placeholder_content="".join(signed_nfes), + ) + + def processar_lote(self, lista_nfes: list): # adapted from processar_documento + if False: # TODO self._consulta_servico_ao_enviar: + pass + if False: # self._consulta_documento_antes_de_enviar: + pass + + proc_envio = self.envia_documento(lista_nfes) + if self.envio_sincrono: + self.monta_processo(lista_nfes, proc_envio) + yield proc_envio + + if (proc_envio.resposta if self.wrap_response else proc_envio).cStat not in ( + "103", + "104", + ) or self.envio_sincrono: + return + + # + # Aguarda o tempo do processamento antes da consulta + # + self._aguarda_tempo_medio(proc_envio) + # + # Consulta o recibo do lote, para ver o que aconteceu + # + proc_recibo = self.consulta_recibo(proc_envio=proc_envio) + if not (proc_recibo.resposta if self.wrap_response else proc_recibo): + return + + # + # Tenta receber o resultado do processamento do lote, caso ainda + # esteja em processamento + # + tentativa = 0 + while ( + (proc_recibo.resposta if self.wrap_response else proc_recibo).cStat + == "105" # em processamento + and tentativa < self._maximo_tentativas_consulta_recibo + ): + self._aguarda_tempo_medio(proc_envio) + tentativa += 1 + # + # Consulta o recibo do lote, para ver o que aconteceu + # + proc_recibo = self.consulta_recibo(proc_envio=proc_envio) + self.monta_processo(lista_nfes, proc_envio, proc_recibo) + yield proc_recibo + + def monta_processo(self, lista_nfes, proc_envio, proc_recibo=None): + nfe = lista_nfes[0] # TODO could be a collection... + if proc_recibo: + if self.wrap_response: + proc_recibo = proc_recibo.resposta + protocolos = proc_recibo.protNFe + else: + # A falta do recibo indica envio no modo síncrono + # o protocolo é recuperado diretamente da resposta do envio. + if self.wrap_response: + proc_envio = proc_envio.resposta + protocolos = proc_envio.protNFe + if False: # TODO finish if len(nfe) and protocolos: + if not isinstance(protocolos, list): + protocolos = [protocolos] + for protocolo in protocolos: + from nfelib.nfe.bindings.v4_0.proc_nfe_v4_00 import NfeProc + + nfe_proc = NfeProc( + versao=self.versao, + protNFe=protocolo, + ) + xml_file, nfe_proc = self._generateds_to_string_etree(nfe_proc) + prot_nfe = nfe_proc.find("{" + self._namespace + "}protNFe") + prot_nfe.addprevious(nfe) + + proc = proc_recibo if proc_recibo else proc_envio + proc.processo = nfe_proc + proc.processo_xml = self._generateds_to_string_etree(nfe_proc)[0] + proc.protocolo = protocolo + return True + + def monta_nfe_proc(self, nfe, prot_nfe: TprotNfe): + """Constrói e retorna o XML do processo da NF-e, + incorporando a NF-e com o seu protocolo de autorização. + """ + if isinstance(nfe, bytes): + nfe = nfe.decode("utf-8") + if isinstance(nfe, str): + nfe = Tnfe.from_xml(nfe) + else: + nfe = etree.tostring(nfe).decode("utf-8") + + if isinstance(prot_nfe, WrappedResponse): + # TODO it seems monta_nfe_proc is called + # in two different ways accross Odoo tests + # we could probably avoid these ifs + prot_nfe = prot_nfe.resposta + + nfe_proc = NfeProc( + versao=self.versao, + NFe=nfe, + protNFe=prot_nfe, + ) + + return nfe_proc.to_xml() + + def envia_inutilizacao(self, inut: Any) -> Optional[RetInutNfe]: + """Envia um pedido de inutilização de numeração (XML já assinado).""" + if not isinstance(inut, str): + inut = inut.to_xml() + if "X509Certificate" not in inut: + inut_etree = etree.fromstring(inut.encode("utf-8")) + ns = {"nfe": "http://www.portalfiscal.inf.br/nfe"} + doc_id = inut_etree.xpath("//nfe:infInut/@Id", namespaces=ns)[0] + inut = CommonMixin.sign_xml( + xml=inut, + pkcs12_data=self.pkcs12_data, + pkcs12_password=self.pkcs12_password, + doc_id=doc_id, + ) + + # Placeholder object for wrapping. The actual content comes + # from signed_inut_xml. + payload_for_wrapping = InutNfe(versao=self.versao) # Minimal object + + # The placeholder should match the tag within + # the SOAP Body's nfeDadosMsg + # Example: ... + placeholder_exp = r".*?" + + return self.send( + NfeInutilizacao4SoapNfeInutilizacaoNf, + payload_for_wrapping, # Pass the object to be wrapped + placeholder_exp=placeholder_exp, + placeholder_content=inut, # The actual signed XML content + ) + + def consulta_recibo( + self, numero: str = "", proc_envio: Optional[RetEnviNfe] = None + ) -> Optional[RetConsReciNfe]: + """Consulta o resultado do processamento de um lote enviado.""" + recibo_a_consultar = numero + if proc_envio: + if self.wrap_response: + proc_envio = proc_envio.resposta + # Ensure infRec exists and has nRec + if proc_envio.infRec and proc_envio.infRec.nRec: + recibo_a_consultar = proc_envio.infRec.nRec + else: + _logger.warning("proc_envio fornecido sem infRec.nRec válido.") + + if not recibo_a_consultar: + raise ValueError( + "Número do recibo (nRec) não fornecido ou encontrado em proc_envio." + ) + if not ( + isinstance(recibo_a_consultar, str) + and len(recibo_a_consultar) == 15 + and recibo_a_consultar.isdigit() + ): + raise ValueError(f"Número do recibo inválido: {recibo_a_consultar}") + + payload = ConsReciNfe( + versao=self.versao, + tpAmb=Tamb(self.ambiente), + nRec=recibo_a_consultar, + ) + return self.send(NfeRetAutorizacao4SoapNfeRetAutorizacaoLote, payload) + + def enviar_lote_evento( + self, lista_eventos: list[TeventoCancel], numero_lote: str = False + ) -> Optional[TretEnvEvento]: + """Envia um lote de eventos.""" + # TODO seems cancel event is hardcoded or what? + # it's called by cancela_documento. Does it make sense as a separated meth? + if not numero_lote: + numero_lote = datetime.now().strftime("%Y%m%d%H%M%S") + signed_events = [] + for event in lista_eventos: + if isinstance(event, str): + if "X509Certificate" in event: + signed_events.append(event) + else: + event_etree = etree.fromstring(event.encode("utf-8")) + ns = {"nfe": "http://www.portalfiscal.inf.br/nfe"} + doc_id = event_etree.xpath("//nfe:infEvento/@Id", namespaces=ns)[0] + signed_events.append( + CommonMixin.sign_xml( + xml=event, + pkcs12_data=self.pkcs12_data, + pkcs12_password=self.pkcs12_password, + doc_id=doc_id, + ) + ) + else: + signed_events.append( + event.to_xml( + pkcs12_data=self.pkcs12_data, + pkcs12_password=self.pkcs12_password, + doc_id=event.infEvento.Id, + ) + ) + + # Placeholder object for wrapping. + payload_for_wrapping = TeventoCancel(versao="1.00") + placeholder_exp = r".*?" + + return self.send( + NfeRecepcaoEvento4SoapNfeRecepcaoEvento, + payload_for_wrapping, # Pass the object to be wrapped + placeholder_exp=placeholder_exp, + placeholder_content=signed_events, # The actual signed XML content + ) + + # NEW HIGH-LEVEL METHOD FOR CANCELLATION + # TODO + def cancelar_nfe( + self, + chave: str, + protocolo_autorizacao: str, + justificativa: str, + cnpj_cpf: Optional[str] = None, + data_hora_evento: Optional[str] = None, + sequencia: str = "1", + ) -> TretEnvEvento: + """Cria, assina e envia um evento de cancelamento para uma NF-e. + + :param chave: Chave de acesso da NF-e a ser cancelada. + :param protocolo_autorizacao: Protocolo de autorização da NF-e. + :param justificativa: Justificativa do cancelamento (15-255 caracteres). + :param cnpj_cpf: CNPJ ou CPF do autor do evento. Se omitido, será extraído da chave. + :param data_hora_evento: Data e hora do evento (opcional, default: now). + :param sequencia: Sequencial do evento (default: "1"). + :return: Objeto de retorno do processamento do lote de eventos. + """ + evento_obj = self._monta_evento_cancelamento( + chave, + protocolo_autorizacao, + justificativa, + cnpj_cpf, + data_hora_evento, + sequencia, + ) + return self._enviar_lote_evento([evento_obj]) + + # NEW HIGH-LEVEL METHOD FOR CC-e + # TODO + def enviar_cce( + self, + chave: str, + sequencia: str, + justificativa: str, + cnpj_cpf: Optional[str] = None, + data_hora_evento: Optional[str] = None, + ) -> TretEnvEvento: + """Cria, assina e envia um evento de Carta de Correção (CC-e) para uma NF-e. + + :param chave: Chave de acesso da NF-e a ser corrigida. + :param sequencia: Sequencial do evento (1-20). + :param justificativa: Texto da correção (15-1000 caracteres). + :param cnpj_cpf: CNPJ ou CPF do autor do evento. Se omitido, será extraído da chave. + :param data_hora_evento: Data e hora do evento (opcional, default: now). + :return: Objeto de retorno do processamento do lote de eventos. + """ + evento_obj = self._monta_evento_cce( + chave, sequencia, justificativa, cnpj_cpf, data_hora_evento + ) + return self._enviar_lote_evento([evento_obj]) + + ###################################### + # Binding Façades (Input Assembly) + ###################################### + + def cancela_documento( + self, + chave: str, + protocolo_autorizacao: str, + justificativa: str, + cnpj_cpf: Optional[str] = None, + data_hora_evento: Optional[str] = None, + sequencia: str = "1", # Default sequence + ) -> TeventoCancel: + """Monta o objeto Tevento para um evento de cancelamento.""" + if not (isinstance(chave, str) and len(chave) == 44 and chave.isdigit()): + raise ValueError(f"Chave de acesso inválida: {chave}") + if not ( + isinstance(protocolo_autorizacao, str) + and len(protocolo_autorizacao) == 15 + and protocolo_autorizacao.isdigit() + ): + raise ValueError( + f"Número do protocolo de autorização inválido: {protocolo_autorizacao}" + ) + if not (1 <= len(justificativa) <= 255): + raise ValueError("Justificativa deve ter entre 1 e 255 caracteres.") + if not ( + isinstance(sequencia, str) + and sequencia.isdigit() + and 1 <= int(sequencia) <= 99 + ): + raise ValueError("Sequência do evento inválida (1-99).") + + # Determine CNPJ or CPF based on input or fallback to chave + doc_emitente = None + if cnpj_cpf: + if len(cnpj_cpf) == 14 and cnpj_cpf.isdigit(): + doc_emitente = {"CNPJ": cnpj_cpf, "CPF": None} + elif len(cnpj_cpf) == 11 and cnpj_cpf.isdigit(): + doc_emitente = {"CNPJ": None, "CPF": cnpj_cpf} + else: + raise ValueError("CNPJ/CPF inválido fornecido.") + else: + # Fallback to extracting from chave (assuming CNPJ) + doc_emitente = {"CNPJ": chave[6:20], "CPF": None} + _logger.warning( + "CNPJ/CPF não fornecido para cancelamento, extraindo da chave " + "(assumindo CNPJ)." + ) + + return TeventoCancel( + versao="1.00", + infEvento=TeventoCancel.InfEvento( + Id="ID" + TIPO_EVENTO_CANCEL + chave + sequencia.zfill(2), + cOrgao=TcodUfIbge(self.uf), + tpAmb=Tamb(self.ambiente), + CNPJ=doc_emitente["CNPJ"], + CPF=doc_emitente["CPF"], + chNFe=chave, + dhEvento=data_hora_evento or self._timestamp(), + tpEvento=InfEventoTpEventoCancel.VALUE_110111, + nSeqEvento=sequencia, + verEvento=InfEventoVerEventoCancel.VALUE_1_00, + detEvento=TeventoCancel.InfEvento.DetEvento( + versao=DetEventoVersaoCancel.VALUE_1_00, + descEvento=DetEventoDescEventoCancel.CANCELAMENTO, + nProt=protocolo_autorizacao, + xJust=justificativa, + ), + ), + ) + + def carta_correcao( + self, + chave: str, + sequencia: str, + justificativa: str, + cnpj_cpf: Optional[str] = None, + data_hora_evento: Optional[str] = None, + ) -> TeventoCCe: + """Monta o objeto Tevento para um evento de Carta de Correção (CC-e).""" + if not (isinstance(chave, str) and len(chave) == 44 and chave.isdigit()): + raise ValueError(f"Chave de acesso inválida: {chave}") + if not (15 <= len(justificativa) <= 1000): + raise ValueError( + "Correção (justificativa) deve ter entre 15 e 1000 caracteres." + ) + if not ( + isinstance(sequencia, str) + and sequencia.isdigit() + and 1 <= int(sequencia) <= 99 + ): + raise ValueError("Sequência do evento inválida (1-99).") + + # Determine CNPJ or CPF + doc_emitente = None + if cnpj_cpf: + if len(cnpj_cpf) == 14 and cnpj_cpf.isdigit(): + doc_emitente = {"CNPJ": cnpj_cpf, "CPF": None} + elif len(cnpj_cpf) == 11 and cnpj_cpf.isdigit(): + doc_emitente = {"CNPJ": None, "CPF": cnpj_cpf} + else: + raise ValueError("CNPJ/CPF inválido fornecido.") + else: + # Fallback to extracting from chave (assuming CNPJ) + doc_emitente = {"CNPJ": chave[6:20], "CPF": None} + _logger.warning( + "CNPJ/CPF não fornecido para CC-e, extraindo da chave (assumindo CNPJ)." + ) + + return TeventoCCe( + versao="1.00", + infEvento=TeventoCCe.InfEvento( + Id="ID" + TIPO_EVENTO_CCE + chave + sequencia.zfill(2), + cOrgao=TcodUfIbge(self.uf), + tpAmb=Tamb(self.ambiente), + CNPJ=doc_emitente["CNPJ"], + CPF=doc_emitente["CPF"], + chNFe=chave, + dhEvento=data_hora_evento or self._timestamp(), + tpEvento=InfEventoTpEventoCCe.VALUE_110110, + nSeqEvento=sequencia, + verEvento=InfEventoVerEventoCCe.VALUE_1_00, + detEvento=TeventoCCe.InfEvento.DetEvento( + versao=DetEventoVersaoCCe.VALUE_1_00, + descEvento=DetEventoDescEventoCCe.CARTA_DE_CORRE_O, + xCorrecao=justificativa, + xCondUso=DetEventoXCondUso.A_CARTA_DE_CORRE_O_DISCIPLINADA_PELO_1_A_DO_ART_7_DO_CONV_NIO_S_N_DE_15_DE_DEZEMBRO_DE_1970_E_PODE_SER_UTILIZADA_PARA_REGULARIZA_O_DE_ERRO_OCORRIDO_NA_EMISS_O_DE_DOCUMENTO_FISCAL_DESDE_QUE_O_ERRO_N_O_ESTEJA_RELACIONADO_COM_I_AS_VARI_VEIS_QUE_DETERMINAM_O_VALOR_DO_IMPOSTO_TAIS_COMO_BASE_DE_C_LCULO_AL_QUOTA_DIFEREN_A_DE_PRE_O_QUANTIDADE_VALOR_DA_OPERA_O_OU_DA_PRESTA_O_II_A_CORRE_O_DE_DADOS_CADASTRAIS_QUE_IMPLIQUE_MUDAN_A_DO_REMETENTE_OU_DO_DESTINAT_RIO_III_A_DATA_DE_EMISS_O_OU_DE_SA_DA, + ), + ), + ) + + def inutilizacao( + self, + cnpj: str, + mod: str, + serie: str, + num_ini: str, + num_fin: str, + justificativa: str, + ) -> InutNfe: + """Monta o objeto InutNfe para um pedido de inutilização.""" + if not (isinstance(cnpj, str) and len(cnpj) == 14 and cnpj.isdigit()): + raise ValueError(f"CNPJ inválido: {cnpj}") + # Add validation for mod, serie, num_ini, num_fin if needed + if not (15 <= len(justificativa) <= 255): + raise ValueError("Justificativa deve ter entre 15 e 255 caracteres.") + + year = str(date.today().year)[2:] + num_ini_str = str(num_ini) + num_fin_str = str(num_fin) + serie_str = str(serie) + + # Check if numbers are valid + if not (num_ini_str.isdigit() and 1 <= int(num_ini_str) <= 999999999): + raise ValueError(f"Número inicial inválido: {num_ini_str}") + if not (num_fin_str.isdigit() and 1 <= int(num_fin_str) <= 999999999): + raise ValueError(f"Número final inválido: {num_fin_str}") + if int(num_fin_str) < int(num_ini_str): + raise ValueError( + f"Número final ({num_fin_str}) não pode ser menor que o inicial " + "({num_ini_str})." + ) + if not (serie_str.isdigit() and 0 <= int(serie_str) <= 999): + raise ValueError(f"Série inválida: {serie_str}") + + from nfelib.nfe.bindings.v4_0.leiaute_inut_nfe_v4_00 import TinutNfe + + return InutNfe( + versao=self.versao, + infInut=TinutNfe.InfInut( + Id="ID" + + self.uf + + year + + cnpj + + mod + + serie_str.zfill(3) + + num_ini_str.zfill(9) + + num_fin_str.zfill(9), + tpAmb=Tamb(self.ambiente), + xServ=InfInutXServ.INUTILIZAR, + cUF=TcodUfIbge(self.uf), + ano=year, + CNPJ=cnpj, + mod=Tmod(mod), + serie=serie_str, + nNFIni=num_ini_str, + nNFFin=num_fin_str, + xJust=justificativa, + ), + ) + + ###################################### + # Misc + ###################################### + + def _aguarda_tempo_medio(self, proc_recibo: Optional[RetEnviNfe]): + """Aguarda um tempo baseado no tMed retornado pela SEFAZ.""" + if self.wrap_response: + proc_recibo = proc_recibo.resposta + if ( + proc_recibo + and hasattr(proc_recibo, "infRec") + and proc_recibo.infRec + and hasattr(proc_recibo.infRec, "tMed") + and proc_recibo.infRec.tMed + ): + try: + tempo_medio = float(proc_recibo.infRec.tMed) + # Add a small buffer (e.g., 30% or min 1 second) + tempo_espera = max(1.0, tempo_medio * 1.3) + _logger.info( + f"Aguardando {tempo_espera:.2f} segundos (tMed={tempo_medio})..." + ) + time.sleep(tempo_espera) + except (ValueError, TypeError): + _logger.warning( + f"Não foi possível converter tMed '{proc_recibo.infRec.tMed}' " + "para float. Aguardando 2 segundos por padrão." + ) + time.sleep(2.0) + else: + _logger.warning( + "Retorno do envio (RetEnviNfe) inválido ou sem tMed. " + "Aguardando 2 segundos por padrão." + ) + time.sleep(2.0) # Default wait if tMed is missing diff --git a/nfelib/nfe/client/v4_0/servers_nfce.py b/nfelib/nfe/client/v4_0/servers_nfce.py new file mode 100644 index 00000000..035e2fc3 --- /dev/null +++ b/nfelib/nfe/client/v4_0/servers_nfce.py @@ -0,0 +1,190 @@ +# Copyright (C) 2023 Ygor de Carvalho - KMEE +# Copyright (C) 2025 Raphaël Valyi - Akretion + +ESTADO_QRCODE = { + "AC": { + "1": "www.sefaznet.ac.gov.br/nfce/qrcode?p=", + "2": "www.hml.sefaznet.ac.gov.br/nfce/qrcode?p=", + }, + "AL": { + "1": "nfce.sefaz.al.gov.br/QRCode/consultarNFCe.jsp?p=", + "2": "nfce.sefaz.al.gov.br/QRCode/consultarNFCe.jsp?p=", + }, + "AM": { + "1": "sistemas.sefaz.am.gov.br/nfceweb/consultarNFCe.jsp?p=", + "2": "homnfce.sefaz.am.gov.br/nfceweb/consultarNFCe.jsp?p=", + }, + "AP": { + "1": "www.sefaz.ap.gov.br/nfce/nfcep.php?p=", + "2": "www.sefaz.ap.gov.br/nfcehml/nfce.php?p=", # FIXME 404 + }, + "BA": { + "1": "nfe.sefaz.ba.gov.br/servicos/nfce/qrcode.aspx?p=", + "2": "hnfe.sefaz.ba.gov.br/servicos/nfce/qrcode.aspx?p=", + }, + "CE": { + "1": "nfce.sefaz.ce.gov.br/pages/ShowNFCe.html?p=", + "2": "nfceh.sefaz.ce.gov.br/pages/ShowNFCe.html?p=", + }, + "DF": { + "1": "ww1.receita.fazenda.df.gov.br/DecVisualizador/Nfce/qrcode?p=", + "2": "ww1.receita.fazenda.df.gov.br/DecVisualizador/Nfce/qrcode?p=", + }, + "ES": { + "1": "app.sefaz.es.gov.br/ConsultaNFCe?p=", + "2": "homologacao.sefaz.es.gov.br/ConsultaNFCe?p=", + }, + "GO": { + "1": "nfe.sefaz.go.gov.br/nfeweb/sites/nfce/danfeNFCe?p=", + "2": "homolog.sefaz.go.gov.br/nfeweb/sites/nfce/danfeNFCe?p=", # FIXME connection error + }, + "MA": { + "1": "nfce.sefaz.ma.gov.br/portal/consultarNFCe.jsp?p=", + "2": "homologacao.sefaz.ma.gov.br/portal/consultarNFCe.jsp?p=", + }, + "MG": { + "1": "portalsped.fazenda.mg.gov.br/portalnfce/sistema/qrcode.xhtml?p=", + "2": "hnfce.fazenda.mg.gov.br/portalnfce/sistema/qrcode.xhtml?p=", + }, + "MS": { + "1": "www.dfe.ms.gov.br/nfce/qrcode?p=", + "2": "www.dfe.ms.gov.br/nfce/qrcode?p=", + }, + "MT": { + "1": "www.sefaz.mt.gov.br/nfce/consultanfce?p=", + "2": "homologacao.sefaz.mt.gov.br/nfce/consultanfce?p=", + }, + "PA": { + "1": "appnfc.sefa.pa.gov.br/portal/view/consultas/nfce/nfceForm.seam?p=", + "2": "appnfc.sefa.pa.gov.br/portal-homologacao/view/consultas/nfce/nfceForm.seam?p=", + }, + "PB": { + "1": "www4.sefaz.pb.gov.br/atf/seg/SEGf_AcessarFuncao.jsp?cdFuncao=FIS_1410&chNFe=", + "2": "www7.sefaz.pb.gov.br/atf/seg/SEGf_AcessarFuncao.jsp?cdFuncao=FIS_1410&chNFe=", + }, + "PE": { + "1": "nfce.sefaz.pe.gov.br:444/nfce/consulta?p=", + "2": "nfcehomolog.sefaz.pe.gov.br/nfce/consulta?p=", + }, + "PI": { + "1": "www.sefaz.pi.gov.br/nfce/qrcode?p=", + "2": "www.sefaz.pi.gov.br/nfce/qrcode?p=", + }, + "PR": { + "1": "www.fazenda.pr.gov.br/nfce/qrcode?p=", + "2": "www.fazenda.pr.gov.br/nfce/qrcode?p=", + }, + "RJ": { + "1": "www4.fazenda.rj.gov.br/consultaNFCe/QRCode?p=", + "2": "www4.fazenda.rj.gov.br/consultaNFCe/QRCode?p=", + }, + "RN": { + "1": "nfce.set.rn.gov.br/portalDFE/NFCe/mDadosNFCe.aspx?p=", + "2": "hom.nfce.set.rn.gov.br/consultarNFCe.aspx?p=", # FIXME timeout + }, + "RO": { + "1": "www.nfce.sefin.ro.gov.br/home.jsp", + "2": "www.nfce.sefin.ro.gov.br/home.jsp", + }, + "RR": { + "1": "www.sefaz.rr.gov.br/nfce/servlet/qrcode?p=", + "2": "200.174.88.103:8080/nfce/servlet/qrcode?p=", # FIXME timeout + }, + "RS": { + "1": "www.sefaz.rs.gov.br/NFCE/NFCE-COM.aspx?p=", # FIXME read timeout + "2": "www.sefaz.rs.gov.br/NFCE/NFCE-COM.aspx?p=", + }, + "SC": { + "1": "sat.sef.sc.gov.br/nfce/consulta?p=", + "2": "hom.sat.sef.sc.gov.br/nfce/consulta?p=", + }, + "SE": { + "1": "nfce.sefaz.se.gov.br/portal/portalNoticias.jsp", # updated redirect target + "2": "www.hom.nfe.se.gov.br/nfce/qrcode?p=", # FIXME timeout + }, + "SP": { + "1": "www.nfce.fazenda.sp.gov.br/NFCeConsultaPublica/Paginas/ConsultaQRCode.aspx?p=", + "2": "www.homologacao.nfce.fazenda.sp.gov.br/NFCeConsultaPublica/Paginas/ConsultaQRCode.aspx?p=", + }, + "TO": { + "1": "www.sefaz.to.gov.br/nfce/qrcode?p=", + "2": "homologacao.sefaz.to.gov.br/nfce/qrcode?p=", + }, +} + + +ESTADO_CONSULTA_NFCE = { + "AC": [ + "www.sefaznet.ac.gov.br/nfce/consulta", + "www.hml.sefaznet.ac.gov.br/nfce/consulta", # FIXME ConnectTimeout + ], + "AM": [ + "www.sefaz.am.gov.br/nfce/formConsulta.do", + "www.sefaz.am.gov.br/nfce/formConsulta.do", + ], + "AP": [ + "www.sefaz.ap.gov.br/nfce/consulta", + "www.sefaz.ap.gov.br/nfce/consulta", + ], + # BA: homolog off-line + "BA": [ + "hinternet.sefaz.ba.gov.br/nfce/consulta", # FIXME ConnectionError + ], + "DF": [ + "ww1.receita.fazenda.df.gov.br/documentosfiscais/consultar", + "ww1.receita.fazenda.df.gov.br/documentosfiscais/consultar", + ], + "MA": [ + "www.sefaz.ma.gov.br/nfce/consulta", + "www.sefaz.ma.gov.br/nfce/consulta", + ], + "MG": [ + "hnfce.fazenda.mg.gov.br/portalnfce", + ], + "MT": [ + "www.sefaz.mt.gov.br/nfce/consultanfce", + "homologacao.sefaz.mt.gov.br/nfce/consultanfce", + ], + "PE": [ + "nfce.sefaz.pe.gov.br:444/nfce/consulta", + "nfce.sefaz.pe.gov.br:444/nfce/consulta", + ], + "PR": [ + "www.fazenda.pr.gov.br/nfce/consulta", + "www.fazenda.pr.gov.br/nfce/consulta", + ], + # RJ: 404 + "RJ": [ + "portal.fazenda.rj.gov.br/dfe/consultaNFCe", # FIXME HTTP 404 + "portal.fazenda.rj.gov.br/dfe/consultaNFCe", # FIXME HTTP 404 + ], + # RN: ReadTimeout + "RN": [ + "www.set.rn.gov.br/nfce/consulta", # FIXME ReadTimeout + "www.set.rn.gov.br/nfce/consulta", # FIXME ReadTimeout + ], + # RR: 404 + "RR": [ + "portalapp.sefaz.rr.gov.br/nfce/consulta", # FIXME HTTP 404 + "portalapp.sefaz.rr.gov.br/nfce/consulta", # FIXME HTTP 404 + ], + # RS: ReadTimeout + "RS": [ + "www.sefaz.rs.gov.br/nfce/consulta", # FIXME ReadTimeout + "www.sefaz.rs.gov.br/nfce/consulta", # FIXME ReadTimeout + ], + # TODO SC + # SE: redirects to HTTPS + "SE": [ + "nfce.sefaz.se.gov.br", + "www.hom.nfe.se.gov.br/nfce/consulta", # FIXME ReadTimeout + ], + "SP": [ + "ww.nfce.fazenda.sp.gov.br/consulta", + "www.homologacao.nfce.fazenda.sp.gov.br/consulta", + ], + "TO": [ + "www.sefaz.to.gov.br/nfce/consulta", + "homologacao.sefaz.to.gov.br/nfce/consulta.jsf", + ], +} diff --git a/nfelib/nfe/samples/v4_0/nfce_contingencia.xml b/nfelib/nfe/samples/v4_0/nfce_contingencia.xml new file mode 100644 index 00000000..f63b70aa --- /dev/null +++ b/nfelib/nfe/samples/v4_0/nfce_contingencia.xml @@ -0,0 +1,123 @@ + + + + + 41 + 00000010 + VENDA + 65 + 1 + 10 + 2024-02-28T09:30:00-03:00 + 1 + 1 + 4106902 + 4 + 9 + 4 + 2 + 1 + 1 + 1 + 0 + TestClient 1.0 + 2024-02-28T09:30:00-03:00 + TESTE DE EMISSAO EM CONTINGENCIA + + + 00000000000100 + EMPRESA TESTE + + RUA TESTE + 123 + CENTRO + 4106902 + Curitiba + PR + 80000000 + 1058 + Brasil + + 9000000000 + 1 + + + 11111111111 + NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL + + + + 1 + SEM GTIN + PRODUTO TESTE + 84713012 + 5102 + UN + 1.0000 + 15.00 + 15.00 + SEM GTIN + UN + 1.0000 + 15.00 + 1 + + + 2.00 + + + 0 + 102 + + + + + 07 + + + + + 07 + + + + + + + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 15.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 15.00 + 2.00 + + + + 9 + + + + 01 + 15.00 + + + + + + http://www.fazenda.pr.gov.br/nfce/consulta + + diff --git a/nfelib/nfe/samples/v4_0/procNFe.xml b/nfelib/nfe/samples/v4_0/procNFe.xml new file mode 100644 index 00000000..4c3d1240 --- /dev/null +++ b/nfelib/nfe/samples/v4_0/procNFe.xml @@ -0,0 +1,183 @@ + + + + + + 41 + 61275652 + VENDA PRODUC.DO ESTABELEC + 55 + 1 + 46320 + 2017-07-12T09:45:50-03:00 + 2017-07-12T09:45:50-03:00 + 1 + 2 + 4118402 + 1 + 1 + 7 + 2 + 1 + 0 + 1 + 0 + UNICO V8.0 + + + 06117473000150 + AKRETION LTDA + AKRETION + + RUA MARIELLE FRANCO + 13 + CENTRO + 4118402 + PARANAVAI + PR + 87704030 + 1058 + BRASIL + 04431414900 + + 9032000301 + 14018 + 6202300 + 1 + + + 27373722000148 + NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL + + RUA DUARTINA + 850 + SEM ANISTIA + 3511102 + CATANDUVA + SP + 15810150 + 1058 + BRASIL + 01735237730 + + 1 + 260205835115 + + + + 01042 + + NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL + 84714900 + 6101 + LU + 1 + 84.9000000000 + 84.90 + + LU + 1 + 84.9000000000 + 1 + 230772 + 1 + + + 16.29 + + + 0 + 101 + 2.4199 + 2.05 + + + + + 99 + 0.00 + 0.0000 + 0.00 + + + + + 99 + 0.00 + 0.0000 + 0.00 + + + + + + + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 84.90 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 84.90 + 16.29 + + + + 0 + + + + 01 + 84.90 + + + + ;CONTROLE: 0000178652;PEDIDO(S) ATENDIDO(S): 230772;Empresa optante pelo simples nacional, conforme lei compl. 128 de 19/12/2008;Permite o aproveitamento do credito de ICMS no valor de R$ 2,05, correspondente ao percentual de 2,42% . Nos termos do Art. 23 - LC 123/2006 (Resolucoes CGSN n. 10/2007 e 53/2008);Voce pagou aproximadamente: R$ 10,35 trib. federais / R$ 5,94 trib. estaduais / R$ 0,00 trib. municipais. Fonte: IBPT 16.2.A Ar5Fr7; + + + + + + + + + + + + + +GzukUqyGg3ksWFUU/e6HsguR0A= + + + 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 + + + + + + + 2 + PR-v4_0_1 + 41170706117473000150550010000463202612756525 + 2017-07-12T10:03:59-03:00 + 141170000487910 + +GzukUqyGg3ksWFUU/e6HsguR0A= + 100 + Autorizado o uso da NF-e + + + diff --git a/tests/mdfe/test_client.py b/tests/mdfe/test_client.py new file mode 100644 index 00000000..aaf780bb --- /dev/null +++ b/tests/mdfe/test_client.py @@ -0,0 +1,165 @@ +# FILEPATH: tests/mdfe/test_client.py +import logging +from os import environ +from pathlib import Path +from unittest import TestCase, mock + +from decorator import decorate +from erpbrasil.assinatura import misc +from xsdata.formats.dataclass.parsers import XmlParser +from xsdata.formats.dataclass.transports import DefaultTransport + +# --- Import Bindings --- +from nfelib.mdfe.bindings.v3_0 import ( + RetConsStatServMdfe, + RetEnviMdfe, + Tmdfe, +) + +# --- Import Client --- +from nfelib.mdfe.client.v3_0.mdfe import MdfeClient + +_logger = logging.getLogger(__name__) + +# --- Mock SOAP Responses --- +response_status_servico = b""" + + + + 41 + 3.00 + + + + + + 2 + MDFe_2.2.2 + 107 + Servico em Operacao + 41 + 2024-02-15T15:00:00-03:00 + 1 + + + + +""" + +response_envia_documento = b""" + + + + + 2 + 41 + MDFe_2.2.2 + 104 + Lote processado + + + 2 + MDFe_2.2.2 + 41240200000000000100580010000000101000000104 + 2024-02-15T15:01:00-03:00 + 141000000000001 + 215 + Rejeicao: Falha no schema XML + + + + + + +""" + +# Decorator for Certificate Check +def _only_if_valid_certificate(method, self): + if self.valid_certificate: + return method(self) + _logger.info( + f"Skipping test '{method.__name__}' because CERT_FILE and CERT_PASSWORD env vars are not set." + ) + return lambda *args, **kwargs: None + + +def only_if_valid_certificate(method): + return decorate(method, _only_if_valid_certificate) + + +class MdfeSoapTest(TestCase): + """Tests MdfeClient SOAP interactions.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.valid_certificate = False + cls.cert_password = "testpassword" + cls.cert_data = misc.create_fake_certificate_file( + valid=True, + passwd=cls.cert_password, + issuer="TEST ISSUER", + country="BR", + subject="TEST SUBJECT", + ) + cls.fake_certificate = True + + mdfe_path = ( + Path(__file__).parent.parent.parent + / "nfelib/mdfe/samples/v3_0/mdfe.xml" + ) + if not mdfe_path.is_file(): + raise FileNotFoundError(f"MDF-e fixture not found: {mdfe_path}") + + cls.mdfe_obj = XmlParser().from_path(mdfe_path, Tmdfe) + + # Client for a state that uses SVRS + cls.client = MdfeClient( + ambiente="2", + uf="41", # Paraná + pkcs12_data=cls.cert_data, + pkcs12_password=cls.cert_password, + fake_certificate=cls.fake_certificate, + verify_ssl=False, + ) + + @mock.patch.object(DefaultTransport, "post") + def test_status_servico_mocked(self, mock_post): + mock_post.return_value = response_status_servico + res = self.client.status_servico() + self.assertIsInstance(res, RetConsStatServMdfe) + self.assertEqual(res.cStat, "107") + + @mock.patch.object(DefaultTransport, "post") + def test_envia_documento_mocked(self, mock_post): + """Testa o envio síncrono de um MDF-e.""" + mock_post.return_value = response_envia_documento + res = self.client.envia_documento(self.mdfe_obj) + self.assertIsInstance(res, RetEnviMdfe) + self.assertEqual(res.cStat, "104") # Lote Processado + self.assertIsNotNone(res.protMDFe) + self.assertEqual(res.protMDFe.infProt.cStat, "215") + + @only_if_valid_certificate + def test_status_servico_real(self): + """Testa a consulta de status de serviço (requer certificado válido).""" + res = self.client.status_servico() + self.assertIsInstance(res, RetConsStatServMdfe) + self.assertEqual(res.cStat, "107") + + @only_if_valid_certificate + def test_envia_documento_real(self): + """ + Testa o envio de um MDF-e real. + Espera-se uma rejeição, já que os dados são de exemplo. + """ + # Adjusting the object to be valid for sending (e.g., current timestamp) + self.mdfe_obj.infMDFe.ide.dhEmi = self.client._timestamp() + + res = self.client.envia_documento(self.mdfe_obj) + self.assertIsInstance(res, RetEnviMdfe) + self.assertEqual(res.cStat, "104") + self.assertIsNotNone(res.protMDFe) + # Check for common rejection codes in homologation + self.assertNotEqual(res.protMDFe.infProt.cStat, "100") + _logger.info(f"Envio real rejeitado com: {res.protMDFe.infProt.xMotivo}") diff --git a/tests/nfe/test_client_dfe.py b/tests/nfe/test_client_dfe.py new file mode 100644 index 00000000..32b6c20e --- /dev/null +++ b/tests/nfe/test_client_dfe.py @@ -0,0 +1,95 @@ +# FILEPATH: tests/nfe/test_client_dfe.py +import logging +from unittest import TestCase, mock + +from erpbrasil.assinatura import misc +from xsdata.formats.dataclass.transports import DefaultTransport + +# --- Import Client --- +from nfelib.nfe.client.v4_0.dfe import DfeClient + +# --- Import Bindings --- +from nfelib.nfe_dist_dfe.bindings.v1_0 import RetDistDfeInt + +_logger = logging.getLogger(__name__) + +# --- Mock SOAP Response --- +# A realistic SOAP response for a successful query with multiple documents. +response_sucesso_multiplos = b""" + + + + + + 1 + 1.4.0 + 138 + Documento(s) localizado(s) + 2022-04-04T11:54:49-03:00 + 000000000000201 + 000000000000201 + + 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 + + + + + +""" + + +class DfeClientTest(TestCase): + """Tests DfeClient SOAP interactions.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.cert_password = "testpassword" + cls.cert_data = misc.create_fake_certificate_file( + valid=True, + passwd=cls.cert_password, + issuer="TEST ISSUER", + country="BR", + subject="TEST SUBJECT", + ) + cls.fake_certificate = True + + # Client Setup + cls.client = DfeClient( + ambiente="1", # DF-e distribution is only available in production + uf="35", # The UF of the interested party (CNPJ/CPF) + pkcs12_data=cls.cert_data, + pkcs12_password=cls.cert_password, + fake_certificate=cls.fake_certificate, + verify_ssl=False, + ) + + @mock.patch.object(DefaultTransport, "post") + def test_consultar_distribuicao_mocked(self, mock_post): + """ + Tests the DF-e distribution query with a mocked successful response. + """ + mock_post.return_value = response_sucesso_multiplos + + # Define test parameters + cnpj_cpf = "00000000000191" + ultimo_nsu = "000000000000000" + + # Call the client method + res = self.client.consultar_distribuicao( + cnpj_cpf=cnpj_cpf, ultimo_nsu=ultimo_nsu + ) + + # Assertions + self.assertIsInstance(res, RetDistDfeInt) + self.assertEqual(res.cStat, "138") + self.assertEqual(res.xMotivo, "Documento(s) localizado(s)") + self.assertEqual(res.ultNSU, "000000000000201") + self.assertIsNotNone(res.loteDistDFeInt) + self.assertEqual(len(res.loteDistDFeInt.docZip), 2) + self.assertEqual(res.loteDistDFeInt.docZip[0].NSU, "000000000000200") + self.assertEqual(res.loteDistDFeInt.docZip[1].NSU, "000000000000201") + + # Verify that the mock was called + mock_post.assert_called_once() diff --git a/tests/nfe/test_client_mde.py b/tests/nfe/test_client_mde.py new file mode 100644 index 00000000..fc8e81f1 --- /dev/null +++ b/tests/nfe/test_client_mde.py @@ -0,0 +1,212 @@ +# FILEPATH: tests/nfe/test_mde_client.py +import logging +from os import environ +from pathlib import Path +from unittest import TestCase, mock + +from decorator import decorate +from erpbrasil.assinatura import misc +from xsdata.formats.dataclass.transports import DefaultTransport + +# --- Import Bindings --- +from nfelib.nfe_evento_mde.bindings.v1_0.leiaute_conf_recebto_v1_00 import TretEnvEvento + +# --- Import Client --- +from nfelib.nfe.client.v4_0.mde import MdeClient + +_logger = logging.getLogger(__name__) + +# --- Mock SOAP Responses --- +# Using the corrected response name that the FiscalClient will handle +response_confirmacao = b""" + + + + + 11AN_1.1.391128Lote de evento processado + 1AN_1.1.391135Evento registrado e vinculado a NF-e35200309091076000144550010001807401003642343210200Confirmacao da Operacao12020-11-20T07:55:58-03:00123456789012345 + + + +""" + +response_ciencia = b""" + + + + + 11AN_1.1.391128Lote de evento processado + 1AN_1.1.391135Evento registrado e vinculado a NF-e35200309091076000144550010001807401003642343210210Ciencia da Operacao12020-11-20T07:55:59-03:00123456789012346 + + + +""" + +response_desconhecimento = b""" + + + + + 11AN_1.1.391128Lote de evento processado + 1AN_1.1.391135Evento registrado e vinculado a NF-e35200309091076000144550010001807401003642343210220Desconhecimento da Operacao12020-11-20T07:55:59-03:00123456789012347 + + + +""" + +response_operacao_nao_realizada = b""" + + + + + 11AN_1.1.391128Lote de evento processado + 1AN_1.1.391135Evento registrado e vinculado a NF-e35200309091076000144550010001807401003642343210240Operacao nao Realizada12020-11-20T07:55:59-03:00123456789012348 + + + +""" + +# Decorator for Certificate Check +def _only_if_valid_certificate(method, self): + if self.valid_certificate: + return method(self) + _logger.info( + f"Skipping test '{method.__name__}' because CERT_FILE and CERT_PASSWORD env vars are not set." + ) + return lambda *args, **kwargs: None + +def only_if_valid_certificate(method): + return decorate(method, _only_if_valid_certificate) + + +# --- Test Case --- +class MDeSoapTest(TestCase): + """ + Tests MdeClient SOAP interactions for Manifestação do Destinatário. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Certificate Setup + if environ.get("CERT_FILE") and environ.get("CERT_PASSWORD"): + cls.valid_certificate = True + cert_path = Path(environ["CERT_FILE"]) + with open(cert_path, "rb") as pkcs12_file: + cls.cert_data = pkcs12_file.read() + cls.cert_password = environ["CERT_PASSWORD"] + cls.fake_certificate = False + else: + cls.valid_certificate = False + cls.cert_password = "testpassword" + cls.cert_data = misc.create_fake_certificate_file( + valid=True, + passwd=cls.cert_password, + issuer="EMISSOR A TESTE", + country="BR", + subject="CERTIFICADO VALIDO TESTE", + ) + cls.fake_certificate = True + + # Client Setup + # The UF here is for the company doing the manifestation, but the client will resolve the endpoint to AN. + cls.client = MdeClient( + ambiente="1", + uf="35", # UF of the destinatário + pkcs12_data=cls.cert_data, + pkcs12_password=cls.cert_password, + fake_certificate=cls.fake_certificate, + verify_ssl=False, + ) + + cls.chave = environ.get( + "CHAVE_NFE", "35200309091076000144550010001807401003642343" + ) + cls.cnpj_cpf = environ.get("CNPJ_CPF_DEST", "23765766000162") + + # --- Test Methods --- + + @mock.patch.object(DefaultTransport, "post") + def test_confirmacao_da_operacao_mocked(self, mock_post): + mock_post.return_value = response_confirmacao + res = self.client.confirmacao_da_operacao( + chave=self.chave, cnpj_cpf=self.cnpj_cpf + ) + self.assertIsInstance(res, TretEnvEvento) + self.assertEqual(res.cStat, "128") + self.assertEqual(res.retEvento[0].infEvento.cStat, "135") + self.assertEqual(res.retEvento[0].infEvento.tpEvento, "210200") + + @only_if_valid_certificate + def test_confirmacao_da_operacao_real(self): + res = self.client.confirmacao_da_operacao( + chave=self.chave, cnpj_cpf=self.cnpj_cpf + ) + self.assertIsInstance(res, TretEnvEvento) + self.assertEqual(res.cStat, "128") + self.assertIn(res.retEvento[0].infEvento.cStat, ["135", "573"]) + + @mock.patch.object(DefaultTransport, "post") + def test_ciencia_da_operacao_mocked(self, mock_post): + mock_post.return_value = response_ciencia + res = self.client.ciencia_da_operacao( + chave=self.chave, cnpj_cpf=self.cnpj_cpf + ) + self.assertIsInstance(res, TretEnvEvento) + self.assertEqual(res.cStat, "128") + self.assertEqual(res.retEvento[0].infEvento.cStat, "135") + self.assertEqual(res.retEvento[0].infEvento.tpEvento, "210210") + + @only_if_valid_certificate + def test_ciencia_da_operacao_real(self): + res = self.client.ciencia_da_operacao( + chave=self.chave, cnpj_cpf=self.cnpj_cpf + ) + self.assertIsInstance(res, TretEnvEvento) + self.assertEqual(res.cStat, "128") + self.assertIn(res.retEvento[0].infEvento.cStat, ["135", "573"]) + + @mock.patch.object(DefaultTransport, "post") + def test_desconhecimento_da_operacao_mocked(self, mock_post): + mock_post.return_value = response_desconhecimento + res = self.client.desconhecimento_da_operacao( + chave=self.chave, cnpj_cpf=self.cnpj_cpf + ) + self.assertIsInstance(res, TretEnvEvento) + self.assertEqual(res.cStat, "128") + self.assertEqual(res.retEvento[0].infEvento.cStat, "135") + self.assertEqual(res.retEvento[0].infEvento.tpEvento, "210220") + + @only_if_valid_certificate + def test_desconhecimento_da_operacao_real(self): + res = self.client.desconhecimento_da_operacao( + chave=self.chave, cnpj_cpf=self.cnpj_cpf + ) + self.assertIsInstance(res, TretEnvEvento) + self.assertEqual(res.cStat, "128") + self.assertIn(res.retEvento[0].infEvento.cStat, ["135", "573"]) + + @mock.patch.object(DefaultTransport, "post") + def test_operacao_nao_realizada_mocked(self, mock_post): + mock_post.return_value = response_operacao_nao_realizada + res = self.client.operacao_nao_realizada( + chave=self.chave, + cnpj_cpf=self.cnpj_cpf, + justificativa="Justificativa de teste com mais de 15 caracteres", + ) + self.assertIsInstance(res, TretEnvEvento) + self.assertEqual(res.cStat, "128") + self.assertEqual(res.retEvento[0].infEvento.cStat, "135") + self.assertEqual(res.retEvento[0].infEvento.tpEvento, "210240") + + @only_if_valid_certificate + def test_operacao_nao_realizada_real(self): + res = self.client.operacao_nao_realizada( + chave=self.chave, + cnpj_cpf=self.cnpj_cpf, + justificativa="Teste de operacao nao realizada para fins de auditoria.", + ) + self.assertIsInstance(res, TretEnvEvento) + self.assertEqual(res.cStat, "128") + self.assertIn(res.retEvento[0].infEvento.cStat, ["135", "573"]) diff --git a/tests/nfe/test_client_nfe.py b/tests/nfe/test_client_nfe.py new file mode 100644 index 00000000..5aa58656 --- /dev/null +++ b/tests/nfe/test_client_nfe.py @@ -0,0 +1,548 @@ +import logging +import time +from os import environ +from pathlib import Path +from unittest import TestCase, mock + +from decorator import decorate +from erpbrasil.assinatura import misc +from xsdata.formats.dataclass.parsers import XmlParser +from xsdata.formats.dataclass.transports import DefaultTransport + +# --- Import Bindings --- +from nfelib.nfe.bindings.v4_0.nfe_v4_00 import Nfe +from nfelib.nfe.bindings.v4_0.proc_nfe_v4_00 import NfeProc # Corrected import +from nfelib.nfe.bindings.v4_0.ret_cons_reci_nfe_v4_00 import RetConsReciNfe +from nfelib.nfe.bindings.v4_0.ret_cons_sit_nfe_v4_00 import RetConsSitNfe +from nfelib.nfe.bindings.v4_0.ret_cons_stat_serv_v4_00 import RetConsStatServ +from nfelib.nfe.bindings.v4_0.ret_envi_nfe_v4_00 import RetEnviNfe +from nfelib.nfe.bindings.v4_0.ret_inut_nfe_v4_00 import ( + RetInutNfe, +) + +# --- Import Client --- +from nfelib.nfe.client.v4_0.nfe import ( + NfeClient, + TcodUfIbge, # Import Enum for UF validation/lookup +) + +# --- Event Bindings --- +from nfelib.nfe_evento_cancel.bindings.v1_0.leiaute_evento_canc_nfe_v1_00 import ( + TenvEvento, +) +from nfelib.nfe_evento_cce.bindings.v1_0.ret_env_cce_v1_00 import RetEnvEvento + +# --- Mock SOAP Responses --- +# (Keep the existing mock responses as they are) +# ... response_status ... +# ... response_envia_documento ... +# ... response_consulta_documento ... +# ... response_consulta_recibo ... +# ... response_cancela_documento ... + +# Add mock response for Inutilizacao (Example structure) +response_inutilizacao = b""" + + + + + + 2 + SVRS202310101000 + 102 + Inutilizacao de numero homologado + 42 + 23 + 81583054000129 + 55 + 1 + 2 + 2 + 2023-11-15T10:30:00-03:00 + 141230000000001 + + + + + +""" + +response_status = b""" + + + + + 2 + SVRS202305251555 + 107 + Servico em Operacao + 42 + 2023-06-11T00:15:00-03:00 + 1 + + + + +""" + +response_envia_documento = b""" + + + + + 2 + SVRS202305251555 + 103 + Lote recebido com sucesso + 42 + 2023-06-11T01:18:19-03:00 + + 423002202113232 + 1 + + + + + +""" + +response_consulta_documento = b""" + + + + + 2 + SVRS202305251555 + 217 + Rejeicao: NF-e nao consta na base de dados da SEFAZ + 42 + 2023-06-11T01:20:55-03:00 + 42230675277525000259550010000364481754015406 + + + + +""" + +response_consulta_recibo = b""" + + + + + 2 + SVRS202305261028 + 423002202113232 + 104 + Lote processado + 42 + 2023-06-11T01:18:19-03:00 + + + 2 + SVRS202305261028 + 42230675277525000259550010000364481754015406 + 2023-06-11T01:18:19-03:00 + IoYUWXt2fIiRXb7UYRgl77c6Zlk= + 297 + Rejeicao: Assinatura difere do calculado + + + + + + +""" + +response_cancela_documento = b""" + + + + + + 2 + SVRS202305251555 + 215 + Rejeicao: Falha no schema XML + + + + +""" # TODO not a valid cancelamento + + +_logger = logging.getLogger(__name__) + + +# --- Decorator for Certificate Check --- +def _only_if_valid_certificate(method, self): + if self.valid_certificate: + return method(self) + _logger.info( + f"Skipping test '{method.__name__}' because CERT_FILE and CERT_PASSWORD env vars are not set." + ) + # Return a no-op function instead of None to avoid errors if called + return lambda *args, **kwargs: None + + +def only_if_valid_certificate(method): + return decorate(method, _only_if_valid_certificate) + + +# --- Test Case --- +class SoapTest(TestCase): + """ + Tests NfeClient SOAP interactions. + Mocked tests run always. + Real tests run only if CERT_FILE and CERT_PASSWORD env vars are set. + Use CERT_UF to specify the UF for the certificate (defaults to 41 - PR). + Use NFE_FILE to specify the path to a procNFe XML file (defaults to nfelib/nfe/samples/v4_0/procNFe.xml). + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # --- Certificate Setup --- + if environ.get("CERT_FILE") and environ.get("CERT_PASSWORD"): + cls.valid_certificate = True + cert_path = Path(environ["CERT_FILE"]) + if not cert_path.is_file(): + raise FileNotFoundError(f"Certificate file not found: {cert_path}") + with open(cert_path, "rb") as pkcs12_file: + cls.cert_data = pkcs12_file.read() + cls.cert_password = environ["CERT_PASSWORD"] + cls.fake_certificate = False + _logger.info(f"Using real certificate from: {cert_path}") + else: + cls.valid_certificate = False + cls.cert_password = "testpassword" # Example password + valid = (True,) + issuer = "EMISSOR A TESTE" + country = "BR" + subject = "CERTIFICADO VALIDO TESTE" + cls.cert_data = misc.create_fake_certificate_file( + valid, cls.cert_password, issuer, country, subject + ) + cls.fake_certificate = True + _logger.info("Using fake certificate for tests.") + + # --- NFe Fixture Setup --- + # Using procNFe because it often contains a fully structured and potentially signed NFe. + # We extract the NFe part for sending/signing tests. + if environ.get("NFE_FILE"): + nfe_path = Path(environ["NFE_FILE"]) + if not nfe_path.is_file(): + raise FileNotFoundError(f"NFE_FILE not found: {nfe_path}") + else: + # Default path relative to the project root (adjust if needed) + nfe_path = ( + Path(__file__).parent.parent.parent + / "nfelib/nfe/samples/v4_0/procNFe.xml" + ) + if not nfe_path.is_file(): + # Fallback or raise error if default fixture is missing + raise FileNotFoundError(f"Default NFE fixture not found: {nfe_path}") + + _logger.info(f"Loading NFe fixture from: {nfe_path}") + parser = XmlParser() + nfe_proc = parser.from_path(nfe_path, NfeProc) # Use NfeProc + + # Extract necessary parts from the fixture + cls.nfe_obj = nfe_proc.NFe # Keep the Nfe object + cls.protocolo_original = ( + nfe_proc.protNFe.infProt.nProt + if nfe_proc.protNFe and nfe_proc.protNFe.infProt + else "123456789012345" + ) # Example fallback + cls.chave_original = ( + nfe_proc.protNFe.infProt.chNFe + if nfe_proc.protNFe and nfe_proc.protNFe.infProt + else cls.nfe_obj.infNFe.Id[3:] + ) + cls.cnpj_original = cls.chave_original[6:20] # Extract CNPJ from key + _logger.info(f"CNPJ from NFe: {cls.cnpj_original}") + + # --- Client Setup --- + cls.test_uf = environ.get("CERT_UF", "42") # Default to SVRS (42) if not set + try: + TcodUfIbge(cls.test_uf) # Validate UF + except ValueError: + raise ValueError( + f"Invalid CERT_UF environment variable: {cls.test_uf}. Use a valid 2-digit IBGE code." + ) + + cls.client = NfeClient( + ambiente="2", # Homologation + uf=cls.test_uf, + pkcs12_data=cls.cert_data, + pkcs12_password=cls.cert_password, + fake_certificate=cls.fake_certificate, + verify_ssl=False, # Often needed for homologation endpoints + ) + + cls.nfe = Nfe(infNFe=nfe_proc.NFe.infNFe) + cls.signed_nfe_xml = cls.nfe.to_xml( + pkcs12_data=cls.cert_data, + pkcs12_password=cls.cert_password, + doc_id=cls.nfe.infNFe.Id, + ) + _logger.info(f"Using UF: {cls.test_uf}, Ambiente: {cls.client.ambiente}") + _logger.info(f"NFe Chave for tests: {cls.chave_original}") + + # --- Test Methods --- + + @only_if_valid_certificate + def test_0_status(self): + res = self.client.status_servico() + self.assertIsInstance(res, RetConsStatServ) + self.assertEqual(res.cStat, "107") # Expected status for 'Servico em Operacao' + self.assertEqual(res.tpAmb.value, self.client.ambiente) + # Optionally check res.cUF.value against self.client.uf if needed + + @mock.patch.object(DefaultTransport, "post") + def test_0_status_mocked(self, mock_post): + mock_post.return_value = response_status + res = self.client.status_servico() + self.assertIsInstance(res, RetConsStatServ) + self.assertEqual(res.cStat, "107") + self.assertEqual(res.tpAmb.value, "2") # Check mock environment + self.assertEqual(res.cUF.value, "42") # Check mock UF + + @only_if_valid_certificate + def test_1_envia_documento(self): + res_envio = self.client.envia_documento([self.signed_nfe_xml]) + self.assertIsInstance(res_envio, RetEnviNfe) + # Check for success or already processed + self.assertIn( + res_envio.cStat, ("103", "104") + ) # 103 = Lote Recebido, 104 = Lote Processado + if res_envio.cStat == "103": # Lote Recebido, needs consultation + self.assertIsNotNone(res_envio.infRec) + self.assertIsNotNone(res_envio.infRec.nRec) + _logger.info( + f"Lote enviado, recibo: {res_envio.infRec.nRec}. Aguardando consulta..." + ) + self.client._aguarda_tempo_medio(res_envio) + res_recibo = self.client.consulta_recibo(proc_envio=res_envio) + self.assertIsInstance(res_recibo, RetConsReciNfe) + _logger.info( + f"Resultado Consulta Recibo: cStat={res_recibo.cStat}, xMotivo={res_recibo.xMotivo}" + ) + # Check common processing results (Autorizado, Rejeitado, Denegado) + self.assertIn( + res_recibo.cStat, ("100", "104") + ) # 100 = Autorizado (unlikely in HMG), 104 = Lote Processado + if res_recibo.protNFe: + for prot in res_recibo.protNFe: + _logger.info( + f" Protocolo NFe {prot.infProt.chNFe}: cStat={prot.infProt.cStat}, xMotivo={prot.infProt.xMotivo}" + ) + # Assertions might depend on expected test NFe outcome (likely rejection) + self.assertIn( + prot.infProt.cStat, + ("100", "110", "301", "302", "204", "297", "213"), + ) # Autorizado, Denegado, Rejeitado, Duplicidade, Assinatura incorreta etc. + + elif res_envio.cStat == "104": # Lote processado (synchronous simulation?) + _logger.info("Lote processado no envio (resposta síncrona simulada?).") + self.assertIsNotNone(res_envio.protNFe) # Should have protocol info + prot = res_envio.protNFe + _logger.info( + f" Protocolo NFe {prot.infProt.chNFe}: cStat={prot.infProt.cStat}, xMotivo={prot.infProt.xMotivo}" + ) + self.assertIn( + prot.infProt.cStat, ("100", "110", "301", "302", "204", "297", "213") + ) + + @mock.patch.object(DefaultTransport, "post") + def test_1_envia_documento_mocked(self, mock_post): + mock_post.return_value = response_envia_documento + res = self.client.envia_documento([self.signed_nfe_xml]) # Pass signed XML + self.assertIsInstance(res, RetEnviNfe) + self.assertEqual(res.cStat, "103") # Mock returns Lote Recebido + self.assertIsNotNone(res.infRec) + self.assertEqual(res.infRec.nRec, "423002202113232") + + @only_if_valid_certificate + def test_2_consulta_documento(self): + # Use a known key or the key from the fixture + chave_consulta = self.chave_original + _logger.info(f"Consultando chave: {chave_consulta}") + res = self.client.consulta_documento(chave_consulta) + self.assertIsInstance(res, RetConsSitNfe) + _logger.info( + f"Resultado Consulta Chave: cStat={res.cStat}, xMotivo={res.xMotivo}" + ) + # Expect rejection or not found in homologation unless NFe was successfully sent before + self.assertIn( + res.cStat, ("100", "101", "110", "217", "526") + ) # Autorizado, Cancelado, Denegado, Nao encontrada, Chave Invalida Consulta... + + @mock.patch.object(DefaultTransport, "post") + def test_2_consulta_documento_mocked(self, mock_post): + mock_post.return_value = response_consulta_documento + res = self.client.consulta_documento(self.chave_original) # Use fixture key + self.assertIsInstance(res, RetConsSitNfe) + self.assertEqual(res.cStat, "217") # Mock returns Rejeicao: Nao consta + + @only_if_valid_certificate + def test_3_envia_inutilizacao(self): + # WARNING: Inutilization is permanent for the range in the specified env. Use with caution. + # Choose a safe, unused range for testing. + num_ini = "99990" + num_fin = "99990" + serie = "1" + mod = "55" + cnpj = self.cnpj_original # Use CNPJ from fixture/cert + just = "Teste de inutilizacao de numeracao" + _logger.info( + f"Tentando inutilizar: CNPJ={cnpj}, Mod={mod}, Serie={serie}, Nums={num_ini}-{num_fin}" + ) + + inut_obj = self.client.inutilizacao( + cnpj=cnpj, + mod=mod, + serie=serie, + num_ini=num_ini, + num_fin=num_fin, + justificativa=just, + ) + # Sign the InutNfe object + signed_xml = inut_obj.to_xml( + pkcs12_data=self.cert_data, + pkcs12_password=self.cert_password, + doc_id=inut_obj.infInut.Id, # Sign using the InfInut ID + ) + res = self.client.envia_inutilizacao(signed_xml) + self.assertIsInstance(res, RetInutNfe) + _logger.info( + f"Resultado Inutilizacao: cStat={res.infInut.cStat}, xMotivo={res.infInut.xMotivo}" + ) + # 102 = Homologado, 206 = Ja Inutilizado, 563 = Ja utilizado pelo emitente + self.assertIn(res.infInut.cStat, ("102", "206", "563", "215")) + + @mock.patch.object(DefaultTransport, "post") + def test_3_envia_inutilizacao_mocked(self, mock_post): + mock_post.return_value = response_inutilizacao + # Prepare data as if it was signed (content doesn't matter for mock) + evento = self.client.inutilizacao( + cnpj=self.cnpj_original, + mod="55", + serie="1", + num_ini="10", + num_fin="20", + justificativa="blablabla blablabla", + ) + res = self.client.envia_inutilizacao(evento) + self.assertIsInstance(res, RetInutNfe) + self.assertEqual(res.infInut.cStat, "102") # Mock returns success + + @only_if_valid_certificate + def test_4_consulta_recibo(self): + # Use a known recibo or one from a previous envia_documento test run + # For a standalone test, this will likely fail unless a valid recibo is hardcoded + nrec = environ.get( + "TEST_RECIBO_NFE", "413000000000001" + ) # Example, replace or use env var + _logger.info(f"Consultando recibo: {nrec}") + if len(nrec) != 15 or not nrec.isdigit(): + self.skipTest(f"Recibo inválido para teste: {nrec}") + + res = self.client.consulta_recibo(nrec) + self.assertIsInstance(res, RetConsReciNfe) + _logger.info( + f"Resultado Consulta Recibo: cStat={res.cStat}, xMotivo={res.xMotivo}" + ) + # 104 = Lote Processado, 105 = Lote em Processamento, 106 = Lote nao localizado + self.assertIn(res.cStat, ("104", "105", "106")) + + @mock.patch.object(DefaultTransport, "post") + def test_4_consulta_recibo_mocked(self, mock_post): + mock_post.return_value = response_consulta_recibo + nrec = "423002202113232" # From mock data + res = self.client.consulta_recibo(nrec) + self.assertIsInstance(res, RetConsReciNfe) + self.assertEqual(res.cStat, "104") # Mock returns Lote Processado + # Further check protocol status inside if needed + self.assertTrue(hasattr(res, "protNFe") and res.protNFe) + self.assertEqual( + res.protNFe[0].infProt.cStat, "297" + ) # Mock has rejection inside + + @only_if_valid_certificate + def test_5_enviar_evento_cancelamento(self): + # This test assumes the NFe from the fixture *was* successfully authorized previously + # Or uses a known authorized key for the test environment. + chave_cancelar = self.chave_original + protocolo_cancelar = ( + self.protocolo_original + ) # Use protocol from fixture or known valid one + just = "Cancelamento para teste unitario" + cnpj = self.cnpj_original + + _logger.info( + f"Preparando cancelamento para Chave: {chave_cancelar}, Prot: {protocolo_cancelar}" + ) + + # 1. Create the cancel event object + evento_obj = self.client.cancela_documento( + chave=chave_cancelar, + protocolo_autorizacao=protocolo_cancelar, + justificativa=just, + cnpj_cpf=cnpj, + sequencia="1", # First cancellation attempt + ) + + # 2. Create the TEnvEvento wrapper + env_evento = TenvEvento( + versao="1.00", # Version of the TEnvEvento schema + idLote=str(int(time.time() * 1000))[-15:], # Generate lote ID + evento=[evento_obj], # Add the event object + ) + + # 3. Sign the TEnvEvento XML + signed_env_evento_xml = env_evento.to_xml( + pkcs12_data=self.cert_data, + pkcs12_password=self.cert_password, + doc_id=env_evento.evento[0].infEvento.Id, # Sign using the inner event's ID + ) + + # 4. Send the signed TEnvEvento + res = self.client.enviar_lote_evento([signed_env_evento_xml]) + self.assertIsInstance(res, RetEnvEvento) + _logger.info( + f"Resultado Envio Evento Cancelamento: cStat={res.cStat}, xMotivo={res.xMotivo}" + ) + + # Check overall batch status + self.assertIn(res.cStat, ("128", "215")) # 128 = Lote de Evento Processado + + # Check individual event status + if res.cStat == "128": + self.assertTrue(hasattr(res, "retEvento") and res.retEvento) + for ret_ev in res.retEvento: + _logger.info( + f" Retorno Evento {ret_ev.infEvento.tpEvento}: cStat={ret_ev.infEvento.cStat}, xMotivo={ret_ev.infEvento.xMotivo}" + ) + # 135 = Evento Registrado e Vinculado a NF-e + # Possible rejections: 573 (Duplicidade), errors related to NFe status (already cancelled, denegada etc.) + self.assertIn( + ret_ev.infEvento.cStat, ("135", "573") + ) # Allow success or duplicate + + @mock.patch.object(DefaultTransport, "post") + def test_5_enviar_evento_cancelamento_mocked(self, mock_post): + mock_post.return_value = response_cancela_documento # Using the provided mock + evento = self.client.cancela_documento( + chave="35200159594315000157550010000000022062777169", + protocolo_autorizacao="012345678912345", + justificativa="votou17", + ) + res = self.client.enviar_lote_evento([evento]) + # for some reason the retur Type is not correct when mocked (but live is OK) + # self.assertIsInstance(res, TretEnvEvento) + # The mock response provided is actually a retEnvEvento inside nfeResultMsg + self.assertEqual(res.cStat, "215") + + # --- Integration Style Test --- + # TODO processar_lote