From de208f4495ebd196921c0669804cee1f461110f0 Mon Sep 17 00:00:00 2001 From: RPSJR Date: Mon, 31 Aug 2026 09:30:06 -0300 Subject: [PATCH 01/11] [ADD] l10_br_fleet_efrotas: base connector for SENATRAN e-Frotas - Implement EfrotasClient supporting Bearer token and digital certificate (A1/mTLS) - Implement endpoints for CRLVe, Consultas, Gerenciamento, Notificacoes, and Transacional - Implement efrotas.config for environment management and connection testing - Extend fleet.vehicle with e-Frotas sync, CRLV-e PDF download and indicators - Implement efrotas.log for HTTP communication auditing - Add efrotas.query.wizard for interactive queries - Add unit tests and live homologation integration test --- l10_br_fleet_efrotas/README.rst | 82 ++ l10_br_fleet_efrotas/__init__.py | 5 + l10_br_fleet_efrotas/__manifest__.py | 39 + l10_br_fleet_efrotas/models/__init__.py | 13 + l10_br_fleet_efrotas/models/efrotas_client.py | 730 ++++++++++++++++++ l10_br_fleet_efrotas/models/efrotas_config.py | 235 ++++++ l10_br_fleet_efrotas/models/efrotas_log.py | 36 + l10_br_fleet_efrotas/models/fleet_vehicle.py | 346 +++++++++ .../security/ir.model.access.csv | 5 + l10_br_fleet_efrotas/security/security.xml | 19 + l10_br_fleet_efrotas/tests/__init__.py | 4 + .../tests/test_efrotas_client.py | 220 ++++++ .../tests/test_live_serpro_homologation.py | 98 +++ .../views/efrotas_config_views.xml | 96 +++ .../views/efrotas_log_views.xml | 81 ++ l10_br_fleet_efrotas/views/efrotas_menus.xml | 23 + .../views/efrotas_query_wizard_views.xml | 59 ++ .../views/fleet_vehicle_views.xml | 110 +++ l10_br_fleet_efrotas/wizard/__init__.py | 9 + .../wizard/efrotas_query_wizard.py | 167 ++++ .../odoo/addons/l10_br_fleet_efrotas | 1 + setup/l10_br_fleet_efrotas/setup.py | 6 + 22 files changed, 2384 insertions(+) create mode 100644 l10_br_fleet_efrotas/README.rst create mode 100644 l10_br_fleet_efrotas/__init__.py create mode 100644 l10_br_fleet_efrotas/__manifest__.py create mode 100644 l10_br_fleet_efrotas/models/__init__.py create mode 100644 l10_br_fleet_efrotas/models/efrotas_client.py create mode 100644 l10_br_fleet_efrotas/models/efrotas_config.py create mode 100644 l10_br_fleet_efrotas/models/efrotas_log.py create mode 100644 l10_br_fleet_efrotas/models/fleet_vehicle.py create mode 100644 l10_br_fleet_efrotas/security/ir.model.access.csv create mode 100644 l10_br_fleet_efrotas/security/security.xml create mode 100644 l10_br_fleet_efrotas/tests/__init__.py create mode 100644 l10_br_fleet_efrotas/tests/test_efrotas_client.py create mode 100644 l10_br_fleet_efrotas/tests/test_live_serpro_homologation.py create mode 100644 l10_br_fleet_efrotas/views/efrotas_config_views.xml create mode 100644 l10_br_fleet_efrotas/views/efrotas_log_views.xml create mode 100644 l10_br_fleet_efrotas/views/efrotas_menus.xml create mode 100644 l10_br_fleet_efrotas/views/efrotas_query_wizard_views.xml create mode 100644 l10_br_fleet_efrotas/views/fleet_vehicle_views.xml create mode 100644 l10_br_fleet_efrotas/wizard/__init__.py create mode 100644 l10_br_fleet_efrotas/wizard/efrotas_query_wizard.py create mode 120000 setup/l10_br_fleet_efrotas/odoo/addons/l10_br_fleet_efrotas create mode 100644 setup/l10_br_fleet_efrotas/setup.py diff --git a/l10_br_fleet_efrotas/README.rst b/l10_br_fleet_efrotas/README.rst new file mode 100644 index 000000000..bc7270af6 --- /dev/null +++ b/l10_br_fleet_efrotas/README.rst @@ -0,0 +1,82 @@ +================================== +Fleet SENATRAN e-Frotas Connector +================================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +|badge1| |badge2| + +Este módulo fornece um conector completo para integração com os webservices do **e-Frotas SENATRAN / SERPRO**, permitindo a gestão centralizada, consulta e sincronização da frota com as bases nacionais de trânsito. + +**Funcionalidades:** + +* **Autenticação:** + * Suporte a Bearer Token JWT (ambiente de Homologação e Produção). + * Suporte a Certificado Digital ICP-Brasil padrão A1 (formato PKCS#12 `.pfx`/`.p12` ou mTLS). + * Envio automático dos cabeçalhos `Authorization` e `x-token-client`. +* **Consultas de Veículos e Frota:** + * Consulta cadastral completa por placa (Renavam, Chassi, Ano de Fabricação/Modelo, Cor, Município, Espécie, Categoria). + * Verificação de associação do veículo ao CNPJ da empresa/filiais. + * Consulta de ocorrências de Roubo e Furto. + * Consulta de chamados de Recall pendente. + * Consulta de restrições judiciais ativas no RENAJUD. +* **CRLV-e Digital:** + * Consulta e download automático do documento CRLV digital em PDF oficial e QR Code direto no registro do veículo. +* **Infrações e SNE:** + * Consulta de infrações não pagas por período de cometimento. + * Consulta detalhada de infração por chave única (Órgão, AIT, Código da Infração). + * Download do PDF da Notificação de Autuação (NA) e Notificação de Penalidade (NP). +* **Gerenciamento e Webhooks (Autorizador):** + * Cadastro, listagem e exclusão de endpoints de Webhooks para recepção de eventos. + * Consulta e ativação de eventos assinados. +* **Notificações:** + * Consulta de notificações/eventos por período e marcação de lido. +* **Transacional:** + * Indicação e cancelamento de Real Infrator (com recuperação do documento assinado). + * Indicação e exclusão de Principal Condutor. + * Reconhecimento de infração e emissão de boletos com/sem desconto SNE de 40%. +* **Auditoria e Logs:** + * Registro de todas as chamadas HTTP com payloads, respostas, duração e status codes em `efrotas.log`. + +Configuração +============ + +1. Acesse o menu **Frota > Configuração > e-Frotas SENATRAN**. +2. Crie ou edite a configuração: + * **Homologação:** Selecione o ambiente *Homologação*, o CNPJ do SERPRO (`33683111000107`) e o Token JWT de testes fornecido no Roteiro de Homologação. + * **Produção:** Selecione o ambiente *Produção*, informe o CNPJ da sua empresa e envie o arquivo do Certificado Digital A1 (`.pfx` / `.p12`) com a senha. +3. Clique em **Testar Conexão** para validar a comunicação com os servidores do SERPRO. + +Uso +=== + +1. No cadastro de qualquer veículo (**Frota > Veículos**): + * Clique em **Consultar e-Frotas** para sincronizar os dados cadastrais e alertas de recall/roubo/renajud. + * Clique em **Baixar CRLV-e** para obter o PDF do documento digital nos anexos. + * Clique em **Verificar Associação** para confirmar se a placa pertence ao CNPJ configurado. +2. Para consultas avulsas, acesse o menu **Frota > Veículos > Consulta e-Frotas SENATRAN**. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. + +Credits +======= + +Authors +~~~~~~~ + +* Fleet Contributors +* Odoo Community Association (OCA) diff --git a/l10_br_fleet_efrotas/__init__.py b/l10_br_fleet_efrotas/__init__.py new file mode 100644 index 000000000..24478b19f --- /dev/null +++ b/l10_br_fleet_efrotas/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 Fleet e-Frotas Contributors +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models +from . import wizard diff --git a/l10_br_fleet_efrotas/__manifest__.py b/l10_br_fleet_efrotas/__manifest__.py new file mode 100644 index 000000000..e75df1588 --- /dev/null +++ b/l10_br_fleet_efrotas/__manifest__.py @@ -0,0 +1,39 @@ +# Copyright 2026 Fleet e-Frotas Contributors +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Fleet SENATRAN e-Frotas Connector", + "summary": """ + Módulo conector e integração com os webservices do e-Frotas SENATRAN / SERPRO: + Consultas de veículos, CRLV-e digital, infrações, restrições RENAJUD, + roubo/furto, recall, webhooks e transações (indicação de condutor, boletos). + """, + "version": "13.0.1.0.0", + "category": "Human Resources/Fleet", + "author": "Fleet Contributors, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/fleet", + "license": "AGPL-3", + "development_status": "Beta", + "depends": [ + "fleet", + "mail", + ], + "external_dependencies": { + "python": [ + "requests", + "cryptography", + ], + }, + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "views/efrotas_config_views.xml", + "views/fleet_vehicle_views.xml", + "views/efrotas_log_views.xml", + "views/efrotas_query_wizard_views.xml", + "views/efrotas_menus.xml", + ], + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/l10_br_fleet_efrotas/models/__init__.py b/l10_br_fleet_efrotas/models/__init__.py new file mode 100644 index 000000000..632d7da30 --- /dev/null +++ b/l10_br_fleet_efrotas/models/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Fleet e-Frotas Contributors +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import efrotas_client + +try: + import odoo + from . import efrotas_config + from . import efrotas_log + from . import fleet_vehicle +except ImportError: + pass + diff --git a/l10_br_fleet_efrotas/models/efrotas_client.py b/l10_br_fleet_efrotas/models/efrotas_client.py new file mode 100644 index 000000000..3df418472 --- /dev/null +++ b/l10_br_fleet_efrotas/models/efrotas_client.py @@ -0,0 +1,730 @@ +# Copyright 2026 Fleet e-Frotas Contributors +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging +import os +import tempfile +import time +import requests + +_logger = logging.getLogger(__name__) + + +class EfrotasException(Exception): + """Exceção base para erros na integração com o e-Frotas.""" + + def __init__(self, message, status_code=None, response_data=None): + super().__init__(message) + self.status_code = status_code + self.response_data = response_data + + +class EfrotasAuthError(EfrotasException): + """Exceção para erros de autenticação (HTTP 401/403).""" + + +class EfrotasNotFoundError(EfrotasException): + """Exceção para recursos não encontrados (HTTP 404).""" + + +class EfrotasValidationError(EfrotasException): + """Exceção para erros de validação ou parâmetros inválidos (HTTP 400/422).""" + + +class EfrotasClient: + """Cliente HTTP para comunicação com a API do e-Frotas SENATRAN / SERPRO. + + Suporta: + - Autenticação via Bearer Token JWT (Homologação e Produção) + - Autenticação via Certificado Digital A1 (PKCS#12 / .pfx / .p12 / mTLS) + - Serviços: CRLVe, Consultas, Gerenciamento (Autorizador), Notificações e Transacional. + """ + + DEFAULT_HOMOLOGATION_URL = ( + "https://hom-efrotas.np.estaleiro.serpro.gov.br/efrotas/api" + ) + DEFAULT_PRODUCTION_URL = "https://efrotas.serpro.gov.br/efrotas/api" + + def __init__( + self, + base_url=None, + token=None, + certificate_data=None, + certificate_password=None, + timeout=30, + log_callback=None, + ): + """Inicializa o cliente e-Frotas. + + :param base_url: URL base da API (ex: https://hom-efrotas.np.estaleiro.serpro.gov.br/efrotas/api) + :param token: Bearer token JWT para autenticação. + :param certificate_data: Conteúdo binário (bytes) do certificado A1 (.pfx/.p12). + :param certificate_password: Senha do certificado digital. + :param timeout: Tempo limite das requisições em segundos. + :param log_callback: Função callable(endpoint, method, status_code, request_body, response_body, duration) para log. + """ + self.base_url = (base_url or self.DEFAULT_HOMOLOGATION_URL).rstrip("/") + self.token = token + self.certificate_data = certificate_data + self.certificate_password = certificate_password + self.timeout = timeout + self.log_callback = log_callback + self._cert_temp_files = [] + + def __del__(self): + self._cleanup_temp_files() + + def _cleanup_temp_files(self): + for path in self._cert_temp_files: + try: + if os.path.exists(path): + os.unlink(path) + except Exception: + pass + self._cert_temp_files.clear() + + def _get_cert_files(self): + """Converte o certificado PKCS#12 em arquivos temporários PEM (cert e chave privada) para requests mTLS.""" + if not self.certificate_data: + return None + + if self._cert_temp_files and len(self._cert_temp_files) == 2: + return tuple(self._cert_temp_files) + + try: + from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + pkcs12, + ) + + pwd_bytes = ( + self.certificate_password.encode("utf-8") + if self.certificate_password + else None + ) + private_key, certificate, additional_certificates = ( + pkcs12.load_key_and_certificates( + self.certificate_data, pwd_bytes + ) + ) + + cert_pem = certificate.public_bytes(Encoding.PEM) + if additional_certificates: + for add_cert in additional_certificates: + cert_pem += add_cert.public_bytes(Encoding.PEM) + + key_pem = private_key.private_bytes( + Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption() + ) + + cert_file = tempfile.NamedTemporaryFile( + delete=False, suffix=".pem", prefix="efrotas_cert_" + ) + cert_file.write(cert_pem) + cert_file.flush() + cert_file.close() + + key_file = tempfile.NamedTemporaryFile( + delete=False, suffix=".pem", prefix="efrotas_key_" + ) + key_file.write(key_pem) + key_file.flush() + key_file.close() + + self._cert_temp_files = [cert_file.name, key_file.name] + return tuple(self._cert_temp_files) + except Exception as e: + _logger.error( + "Falha ao processar certificado digital PKCS#12: %s", e + ) + raise EfrotasAuthError( + f"Erro ao processar certificado digital: {e}" + ) + + def _build_url(self, service, path): + """Monta a URL completa do endpoint. + + :param service: 'crlv', 'consultas', 'autorizador', 'notificacoes', 'transacional' + :param path: Subcaminho da API (ex: '/v1/veiculos') + """ + clean_path = "/" + path.lstrip("/") + return f"{self.base_url}/{service}{clean_path}" + + def _request( + self, + service, + path, + method="GET", + params=None, + data=None, + json_data=None, + headers=None, + ): + """Executa a requisição HTTP com os cabeçalhos de autenticação e tratamento de erros.""" + url = self._build_url(service, path) + req_headers = { + "Accept": "application/json", + "User-Agent": "Odoo-Fleet-eFrotas-Connector/1.0", + } + + if self.token: + clean_token = self.token.strip() + if not clean_token.lower().startswith("bearer "): + req_headers["Authorization"] = f"Bearer {clean_token}" + req_headers["x-token-client"] = clean_token + else: + raw_token = clean_token.split(" ", 1)[1] + req_headers["Authorization"] = clean_token + req_headers["x-token-client"] = raw_token + + if headers: + req_headers.update(headers) + + cert_files = self._get_cert_files() + + start_time = time.time() + response = None + duration = 0.0 + + try: + response = requests.request( + method=method.upper(), + url=url, + params=params, + data=data, + json=json_data, + headers=req_headers, + cert=cert_files, + timeout=self.timeout, + ) + duration = round(time.time() - start_time, 3) + + # Callback de auditoria/log se configurado + if self.log_callback: + try: + req_payload = ( + json.dumps(json_data) + if json_data + else (str(data) if data else "") + ) + self.log_callback( + url=url, + method=method.upper(), + status_code=response.status_code, + request_payload=req_payload, + response_payload=response.text, + duration=duration, + ) + except Exception as log_err: + _logger.warning( + "Falha ao executar log_callback e-Frotas: %s", log_err + ) + + if response.status_code == 401 or response.status_code == 403: + raise EfrotasAuthError( + f"Erro de autenticação e-Frotas ({response.status_code}): {response.text}", + status_code=response.status_code, + response_data=self._safe_json(response), + ) + + if response.status_code == 404: + raise EfrotasNotFoundError( + f"Recurso não encontrado ({response.status_code}): {response.text}", + status_code=response.status_code, + response_data=self._safe_json(response), + ) + + if response.status_code in (400, 422): + msg = self._extract_error_message(response) + raise EfrotasValidationError( + f"Erro de validação e-Frotas ({response.status_code}): {msg}", + status_code=response.status_code, + response_data=self._safe_json(response), + ) + + response.raise_for_status() + + if ( + response.text + and "application/json" + in response.headers.get("Content-Type", "") + ): + return response.json() + elif response.text: + try: + return response.json() + except Exception: + return response.text + return {} + + except requests.RequestException as req_ex: + duration = round(time.time() - start_time, 3) + if self.log_callback: + try: + self.log_callback( + url=url, + method=method.upper(), + status_code=getattr(response, "status_code", 0) or 0, + request_payload=json.dumps(json_data) + if json_data + else "", + response_payload=str(req_ex), + duration=duration, + ) + except Exception: + pass + + if isinstance( + req_ex, + ( + EfrotasAuthError, + EfrotasNotFoundError, + EfrotasValidationError, + ), + ): + raise + raise EfrotasException( + f"Erro de comunicação com e-Frotas: {req_ex}" + ) from req_ex + + def _safe_json(self, response): + try: + return response.json() + except Exception: + return None + + def _extract_error_message(self, response): + try: + data = response.json() + if isinstance(data, dict): + return ( + data.get("mensagem") + or data.get("mensagemTecnica") + or data.get("message") + or data.get("error") + or str(data) + ) + return str(data) + except Exception: + return response.text + + # ========================================================================= + # 1. CRLVe API (Documento Digital CRLV-e) + # ========================================================================= + + def get_crlv(self, placa): + """Consulta unitariamente o CRLV-e de um veículo por placa. + + :param placa: Placa do veículo (ex: 'SAV0741') + :return: dict contendo pdfBase64, qrCode, anoLicenciamento, etc. + """ + clean_placa = placa.replace("-", "").strip().upper() + return self._request("crlv", f"/v1/documento/placa/{clean_placa}") + + # ========================================================================= + # 2. Consultas API + # ========================================================================= + + def get_veiculos(self, cnpj_filial=None, pagina=1, quantidade=20): + """Consulta lista de veículos de um CNPJ (matriz ou filial). + + :param cnpj_filial: CNPJ da filial (opcional) + :param pagina: Número da página (padrão 1) + :param quantidade: Quantidade por página (padrão 20) + """ + params = {"pagina": pagina, "quantidade": quantidade} + if cnpj_filial: + params["cnpjFilial"] = cnpj_filial.replace(".", "").replace( + "/", "" + ).replace("-", "").strip() + return self._request("consultas", "/v1/veiculos", params=params) + + def get_veiculo_por_placa(self, placa): + """Consulta de dados cadastrais completos de um veículo por placa. + + :param placa: Placa do veículo + """ + clean_placa = placa.replace("-", "").strip().upper() + return self._request( + "consultas", f"/v1/veiculos/placa/{clean_placa}" + ) + + def check_veiculo_associado(self, placa): + """Verifica se um veículo está associado à frota do CNPJ. + + :param placa: Placa do veículo + """ + clean_placa = placa.replace("-", "").strip().upper() + return self._request( + "consultas", f"/v1/veiculos/placa/{clean_placa}/associado" + ) + + def get_ocorrencias_roubo_furto(self, placa): + """Consulta histórico de ocorrência de roubo e furto por placa. + + :param placa: Placa do veículo + """ + clean_placa = placa.replace("-", "").strip().upper() + return self._request( + "consultas", + f"/v1/veiculos/placa/{clean_placa}/ocorrencias-roubo-furto", + ) + + def get_recall(self, placa): + """Consulta registros de recall pendente por placa. + + :param placa: Placa do veículo + """ + clean_placa = placa.replace("-", "").strip().upper() + return self._request( + "consultas", f"/v1/veiculos/placa/{clean_placa}/recall" + ) + + def get_restricoes_renajud( + self, placa, pagina=1, quantidade=50, tipo_restricao=None + ): + """Consulta restrições judiciais ativas (RENAJUD) por placa. + + :param placa: Placa do veículo + :param pagina: Página + :param quantidade: Itens por página + :param tipo_restricao: Filtro por tipo de restrição (opcional) + """ + clean_placa = placa.replace("-", "").strip().upper() + params = {"pagina": pagina, "quantidade": quantidade} + if tipo_restricao: + params["tipoRestricao"] = tipo_restricao + return self._request( + "consultas", + f"/v1/veiculos/placa/{clean_placa}/restricoes-renajud", + params=params, + ) + + def get_infracoes_veiculo(self, placa, data_inicial, data_final): + """Consulta infrações não pagas de um veículo em um período. + + :param placa: Placa do veículo + :param data_inicial: Data início formato 'YYYY-MM-DD' + :param data_final: Data fim formato 'YYYY-MM-DD' (limite de 1 ano) + """ + clean_placa = placa.replace("-", "").strip().upper() + params = {"dataInicial": data_inicial, "dataFinal": data_final} + return self._request( + "consultas", + f"/v1/infracoes/placa/{clean_placa}", + params=params, + ) + + def get_detalhes_infracao( + self, codigo_orgao, numero_ait, codigo_infracao + ): + """Consulta detalhada de uma infração a partir da sua chave composta. + + :param codigo_orgao: Código do órgão autuador + :param numero_ait: Número do Auto de Infração de Trânsito + :param codigo_infracao: Código da infração + """ + return self._request( + "consultas", + f"/v1/infracoes/codigoOrgao/{codigo_orgao}/numeroAit/{numero_ait}/codigoInfracao/{codigo_infracao}", + ) + + def get_pdf_notificacao_autuacao( + self, placa, codigo_orgao, numero_ait, codigo_infracao + ): + """Consulta o PDF da Notificação de Autuação (NA) no SNE. + + :return: dict contendo base64 do PDF da Notificação de Autuação + """ + clean_placa = placa.replace("-", "").strip().upper() + return self._request( + "consultas", + f"/sne/pdf/placa/{clean_placa}/codigoOrgao/{codigo_orgao}/numeroAit/{numero_ait}/codigoInfracao/{codigo_infracao}/NA", + ) + + def get_pdf_notificacao_penalidade( + self, placa, codigo_orgao, numero_ait, codigo_infracao + ): + """Consulta o PDF da Notificação de Penalidade (NP) no SNE. + + :return: dict contendo base64 do PDF da Notificação de Penalidade + """ + clean_placa = placa.replace("-", "").strip().upper() + return self._request( + "consultas", + f"/sne/pdf/placa/{clean_placa}/codigoOrgao/{codigo_orgao}/numeroAit/{numero_ait}/codigoInfracao/{codigo_infracao}/NP", + ) + + # ========================================================================= + # 3. Gerenciamento / Autorizador API (Webhooks / Endpoints de Eventos) + # ========================================================================= + + def get_endpoints(self): + """Consulta a lista de endpoints (webhooks) cadastrados.""" + return self._request("autorizador", "/v1/endpoint") + + def create_endpoint(self, endpoint_payload): + """Cadastra um novo endpoint (webhook) para recebimento de eventos. + + :param endpoint_payload: dict com dados do endpoint (URL, headers, eventos, etc.) + """ + return self._request( + "autorizador", + "/v1/endpoint", + method="PUT", + json_data=endpoint_payload, + ) + + def delete_endpoint(self, endpoint_id): + """Exclui um endpoint de webhook cadastrado pelo ID.""" + return self._request( + "autorizador", f"/v1/endpoint/{endpoint_id}", method="DELETE" + ) + + def get_eventos(self, todos=False): + """Consulta eventos configurados (todos ou apenas ativos). + + :param todos: bool indicando se deve retornar todos ou apenas ativos + """ + return self._request( + "autorizador", + "/v1/eventos", + params={"todos": "true" if todos else "false"}, + ) + + def save_eventos(self, eventos_payload): + """Salva a configuração de eventos autorizados.""" + return self._request( + "autorizador", + "/v1/eventos", + method="PUT", + json_data=eventos_payload, + ) + + # ========================================================================= + # 4. Notificações / Eventos API + # ========================================================================= + + def get_notificacoes( + self, + data_inicio, + data_fim, + lido=None, + pagina=None, + quantidade=None, + ): + """Consulta notificações de eventos recebidos pela frota por período. + + :param data_inicio: Data de início (formato ddMMyyyy ou YYYY-MM-DD) + :param data_fim: Data de término + :param lido: Filtro por lido (True/False/None) + :param pagina: Página + :param quantidade: Quantidade + """ + params = {} + if lido is not None: + params["lido"] = "true" if lido else "false" + if pagina is not None: + params["pagina"] = pagina + if quantidade is not None: + params["quantidade"] = quantidade + return self._request( + "notificacoes", + f"/v1/dataInicio/{data_inicio}/dataFim/{data_fim}", + params=params, + ) + + def mark_notificacao_lida(self, payload): + """Marca uma notificação como lida.""" + return self._request( + "notificacoes", + "/v1/lido", + method="POST", + json_data=payload, + ) + + def mark_notificacao_rastreamento_lida(self, payload): + """Marca o rastreamento de uma notificação como lido.""" + return self._request( + "notificacoes", + "/v1/lido/rastreamento", + method="POST", + json_data=payload, + ) + + # ========================================================================= + # 5. Transacional API + # ========================================================================= + + def inserir_indicacao_real_infrator(self, payload): + """Realiza a indicação do Real Infrator para uma infração de trânsito.""" + return self._request( + "transacional", + "/v1/realinfrator/indicacoes/inserir", + method="POST", + json_data=payload, + ) + + def cancelar_indicacao_real_infrator(self, chave_indicacao): + """Cancela uma indicação pendente de Real Infrator.""" + return self._request( + "transacional", + f"/v1/realinfrator/indicacoes/{chave_indicacao}/cancelar", + method="POST", + ) + + def get_status_indicacao_real_infrator( + self, chave_indicacao, cnpj_filial=None + ): + """Consulta o status de uma indicação de real infrator pela chave.""" + params = {} + if cnpj_filial: + params["cnpjFilial"] = cnpj_filial + return self._request( + "transacional", + f"/v1/realinfrator/indicacoes/{chave_indicacao}/status", + params=params, + ) + + def get_documento_assinado_real_infrator( + self, + chave_indicacao, + codigo_orgao, + numero_ait, + codigo_infracao, + cnpj_filial=None, + ): + """Consulta o documento assinado eletronicamente com o aceite do real infrator.""" + params = {} + if cnpj_filial: + params["cnpjFilial"] = cnpj_filial + return self._request( + "transacional", + f"/v1/realinfrator/indicacoes/{chave_indicacao}/{codigo_orgao}/{numero_ait}/{codigo_infracao}/documentoAssinado", + params=params, + ) + + def get_historico_indicacao_real_infrator( + self, codigo_orgao, numero_ait, codigo_infracao + ): + """Consulta o histórico de indicações de real infrator de uma infração.""" + return self._request( + "transacional", + f"/v1/realinfrator/indicacoes/historico/{codigo_orgao}/{numero_ait}/{codigo_infracao}", + ) + + def inserir_indicacao_principal_condutor(self, payload): + """Realiza indicação de Principal Condutor para um veículo.""" + return self._request( + "transacional", + "/v1/principalcondutor/indicacoes/inserir", + method="POST", + json_data=payload, + ) + + def excluir_indicacao_principal_condutor(self, payload): + """Exclui a indicação de Principal Condutor.""" + return self._request( + "transacional", + "/v1/principalcondutor/indicacoes/excluir", + method="POST", + json_data=payload, + ) + + def get_status_indicacao_principal_condutor(self, chave_indicacao): + """Consulta o status de uma indicação de principal condutor.""" + params = {"chaveIndicacao": chave_indicacao} + return self._request( + "transacional", + "/v1/principalcondutor/indicacoes/status", + params=params, + ) + + def get_historico_indicacao_principal_condutor( + self, + placa, + identificacao_possuidor, + data_inicio, + data_fim, + cpf_principal_condutor=None, + omitir_excluidas=True, + ): + """Consulta indicações de principal condutor efetivadas no Renavam no período.""" + params = { + "placa": placa.replace("-", "").strip().upper(), + "identificacaoPossuidor": identificacao_possuidor, + "dataInicio": data_inicio, + "dataFim": data_fim, + "omitirExcluidas": "true" if omitir_excluidas else "false", + } + if cpf_principal_condutor: + params["cpfPrincipalCondutor"] = cpf_principal_condutor + return self._request( + "transacional", + "/v1/principalcondutor/indicacoes/historico", + params=params, + ) + + def solicitar_boleto( + self, placa, chave_infracao, desconto40=False, cnpj_filial=None + ): + """Solicita boleto bancário para pagamento de infração com ou sem desconto SNE (40%).""" + params = { + "placa": placa.replace("-", "").strip().upper(), + "chaveInfracao": chave_infracao, + "desconto40": "true" if desconto40 else "false", + } + if cnpj_filial: + params["cnpjFilial"] = cnpj_filial + return self._request( + "transacional", + "/v1/boleto/solicitarBoleto", + method="POST", + params=params, + ) + + def reconhecer_infracao(self, placa, chave_infracao, cnpj_filial=None): + """Reconhece o cometimento de uma infração de trânsito no SNE.""" + params = { + "placa": placa.replace("-", "").strip().upper(), + "chaveInfracao": chave_infracao, + } + if cnpj_filial: + params["cnpjFilial"] = cnpj_filial + return self._request( + "transacional", + "/v1/boleto/reconhecerInfracao", + method="POST", + params=params, + ) + + def get_pdf_boleto(self, placa, chave_infracao, cnpj_filial=None): + """Consulta o PDF do boleto gerado para pagamento da infração.""" + params = { + "placa": placa.replace("-", "").strip().upper(), + "chaveInfracao": chave_infracao, + } + if cnpj_filial: + params["cnpjFilial"] = cnpj_filial + return self._request( + "transacional", + "/v1/boleto/consultarPdfBoleto", + params=params, + ) + + def get_dados_pagamento(self, placa, chave_infracao, cnpj_filial=None): + """Consulta dados para pagamento (linha digitável, código de barras, valor, etc.).""" + params = { + "placa": placa.replace("-", "").strip().upper(), + "chaveInfracao": chave_infracao, + } + if cnpj_filial: + params["cnpjFilial"] = cnpj_filial + return self._request( + "transacional", + "/v1/boleto/consultarDadosPagamento", + params=params, + ) diff --git a/l10_br_fleet_efrotas/models/efrotas_config.py b/l10_br_fleet_efrotas/models/efrotas_config.py new file mode 100644 index 000000000..7bcfdeddc --- /dev/null +++ b/l10_br_fleet_efrotas/models/efrotas_config.py @@ -0,0 +1,235 @@ +# Copyright 2026 Fleet e-Frotas Contributors +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import logging +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from .efrotas_client import EfrotasClient, EfrotasException + +_logger = logging.getLogger(__name__) + +DEFAULT_HOMOLOGATION_TOKEN = ( + "eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDAxMDY4MDAsImlzcyI6Imh0dHBzOi8vZWZyb3Rh" + "cy5zZXJwcm8uZ292LmJyL2lzc3VlciIsImNucGoiOiIzMzY4MzExMTAwMDEwNyJ9.GnYpQDIZ" + "Qtprkqp3pv1BEUvf3mHAzpIATlgeCoJbuw4" +) + + +class EfrotasConfig(models.Model): + _name = "efrotas.config" + _description = "Configuração e Credenciais do e-Frotas SENATRAN" + _order = "sequence, id" + + name = fields.Char( + string="Nome da Configuração", + required=True, + default="Configuração e-Frotas", + ) + sequence = fields.Integer(string="Sequência", default=10) + active = fields.Boolean(string="Ativo", default=True) + company_id = fields.Many2one( + "res.company", + string="Empresa", + default=lambda self: self.env.company, + required=True, + ) + environment = fields.Selection( + [ + ("homologation", "Homologação (Estaleiro SERPRO)"), + ("production", "Produção (SERPRO / SENATRAN)"), + ("custom", "Personalizado"), + ], + string="Ambiente", + default="homologation", + required=True, + ) + base_url = fields.Char( + string="URL Base da API", + required=True, + default=EfrotasClient.DEFAULT_HOMOLOGATION_URL, + help="URL base dos serviços e-Frotas (ex: https://hom-efrotas.np.estaleiro.serpro.gov.br/efrotas/api)", + ) + auth_mode = fields.Selection( + [ + ("token", "Token JWT (Bearer Token)"), + ("certificate", "Certificado Digital A1 (mTLS / PKCS#12)"), + ], + string="Modo de Autenticação", + default="token", + required=True, + ) + cnpj = fields.Char( + string="CNPJ Matriz / Titular", + default="33683111000107", + help="CNPJ do titular do contrato e-Frotas (para homologação: 33.683.111/0001-07 - SERPRO)", + ) + cnpj_filial = fields.Char( + string="CNPJ Filial Padrão", + help="CNPJ da filial a ser utilizado como padrão nas consultas, se aplicável.", + ) + token = fields.Text( + string="Token de Autenticação JWT", + default=DEFAULT_HOMOLOGATION_TOKEN, + help="Token JWT para envio no cabeçalho Authorization e x-token-client.", + ) + certificate_file = fields.Binary( + string="Arquivo do Certificado A1 (.pfx / .p12)", + help="Upload do certificado digital ICP-Brasil modelo A1 (formato PKCS#12).", + ) + certificate_filename = fields.Char(string="Nome do Arquivo do Certificado") + certificate_password = fields.Char( + string="Senha do Certificado Digital", + ) + timeout = fields.Integer( + string="Timeout de Requisição (segundos)", + default=30, + required=True, + ) + enable_logging = fields.Boolean( + string="Habilitar Logs Detalhados", + default=True, + help="Registra todas as requisições e respostas no histórico de logs para auditoria.", + ) + last_test_date = fields.Datetime( + string="Último Teste de Conexão", readonly=True + ) + last_test_result = fields.Char( + string="Resultado do Último Teste", readonly=True + ) + last_test_status = fields.Selection( + [("success", "Sucesso"), ("failed", "Falha")], + string="Status do Último Teste", + readonly=True, + ) + + @api.onchange("environment") + def _onchange_environment(self): + if self.environment == "homologation": + self.base_url = EfrotasClient.DEFAULT_HOMOLOGATION_URL + self.auth_mode = "token" + self.cnpj = "33683111000107" + if not self.token or self.token.strip() == "": + self.token = DEFAULT_HOMOLOGATION_TOKEN + elif self.environment == "production": + self.base_url = EfrotasClient.DEFAULT_PRODUCTION_URL + self.auth_mode = "certificate" + + def _log_communication( + self, + url, + method, + status_code, + request_payload, + response_payload, + duration, + vehicle_id=None, + ): + """Grava registro no modelo efrotas.log.""" + if not self.enable_logging: + return + try: + self.env["efrotas.log"].sudo().create( + { + "endpoint": url, + "method": method, + "status_code": status_code, + "duration": duration, + "request_payload": request_payload, + "response_payload": (response_payload or "")[ + :100000 + ], # trunca respostas gigantes + "company_id": self.company_id.id, + "vehicle_id": vehicle_id, + } + ) + except Exception as e: + _logger.warning("Erro ao registrar log e-Frotas: %s", e) + + def get_client(self, vehicle_id=None): + """Retorna uma instância configurada de EfrotasClient.""" + self.ensure_one() + cert_data = None + if self.auth_mode == "certificate" and self.certificate_file: + cert_data = base64.b64decode(self.certificate_file) + + def log_cb( + url, + method, + status_code, + request_payload, + response_payload, + duration, + ): + self._log_communication( + url=url, + method=method, + status_code=status_code, + request_payload=request_payload, + response_payload=response_payload, + duration=duration, + vehicle_id=vehicle_id, + ) + + return EfrotasClient( + base_url=self.base_url, + token=self.token if self.auth_mode == "token" else None, + certificate_data=cert_data, + certificate_password=self.certificate_password, + timeout=self.timeout or 30, + log_callback=log_cb if self.enable_logging else None, + ) + + def action_test_connection(self): + """Testa a conectividade com o serviço e-Frotas e valida as credenciais.""" + self.ensure_one() + client = self.get_client() + + try: + # Em homologação, testa consultando a frota ou a placa de teste SAV0741 + test_plate = "SAV0741" + res = client.get_veiculo_por_placa(test_plate) + placa_retornada = ( + res.get("placa") if isinstance(res, dict) else str(res) + ) + + msg = _("Conexão estabelecida com sucesso! Veículo de teste: %s") % ( + placa_retornada or test_plate + ) + self.write( + { + "last_test_date": fields.Datetime.now(), + "last_test_result": msg, + "last_test_status": "success", + } + ) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("e-Frotas SENATRAN"), + "message": msg, + "type": "success", + "sticky": False, + }, + } + except EfrotasException as ex: + msg = _("Falha na conexão com e-Frotas: %s") % str(ex) + self.write( + { + "last_test_date": fields.Datetime.now(), + "last_test_result": msg, + "last_test_status": "failed", + } + ) + raise UserError(msg) + except Exception as ex: + msg = _("Erro inesperado ao testar conexão: %s") % str(ex) + self.write( + { + "last_test_date": fields.Datetime.now(), + "last_test_result": msg, + "last_test_status": "failed", + } + ) + raise UserError(msg) diff --git a/l10_br_fleet_efrotas/models/efrotas_log.py b/l10_br_fleet_efrotas/models/efrotas_log.py new file mode 100644 index 000000000..6a0ef8dcc --- /dev/null +++ b/l10_br_fleet_efrotas/models/efrotas_log.py @@ -0,0 +1,36 @@ +# Copyright 2026 Fleet e-Frotas Contributors +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class EfrotasLog(models.Model): + _name = "efrotas.log" + _description = "e-Frotas Communication Log" + _order = "create_date desc" + _rec_name = "endpoint" + + endpoint = fields.Char(string="Endpoint", required=True, readonly=True) + method = fields.Selection( + [ + ("GET", "GET"), + ("POST", "POST"), + ("PUT", "PUT"), + ("DELETE", "DELETE"), + ], + string="Método", + readonly=True, + ) + status_code = fields.Integer(string="Status HTTP", readonly=True) + duration = fields.Float(string="Duração (s)", readonly=True) + request_payload = fields.Text(string="Payload Requisição", readonly=True) + response_payload = fields.Text(string="Payload Resposta", readonly=True) + company_id = fields.Many2one( + "res.company", + string="Empresa", + default=lambda self: self.env.company, + readonly=True, + ) + vehicle_id = fields.Many2one( + "fleet.vehicle", string="Veículo", readonly=True + ) diff --git a/l10_br_fleet_efrotas/models/fleet_vehicle.py b/l10_br_fleet_efrotas/models/fleet_vehicle.py new file mode 100644 index 000000000..8ae68d338 --- /dev/null +++ b/l10_br_fleet_efrotas/models/fleet_vehicle.py @@ -0,0 +1,346 @@ +# Copyright 2026 Fleet e-Frotas Contributors +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import logging +from odoo import _, fields, models +from odoo.exceptions import UserError +from .efrotas_client import EfrotasException + +_logger = logging.getLogger(__name__) + + +class FleetVehicle(models.Model): + _inherit = "fleet.vehicle" + + # Campos e-Frotas SENATRAN + efrotas_config_id = fields.Many2one( + "efrotas.config", + string="Configuração e-Frotas", + help="Configuração e-Frotas utilizada para este veículo. Se não informada, usará a ativa da empresa.", + ) + efrotas_synced = fields.Boolean( + string="Sincronizado e-Frotas", readonly=True + ) + efrotas_last_sync = fields.Datetime( + string="Última Sincronização", readonly=True + ) + efrotas_renavam = fields.Char(string="Renavam", readonly=True) + efrotas_municipio = fields.Char( + string="Município de Emplacamento", readonly=True + ) + efrotas_chassi = fields.Char(string="Chassi (e-Frotas)", readonly=True) + efrotas_cor = fields.Char(string="Cor", readonly=True) + efrotas_ano_fabricacao = fields.Integer( + string="Ano Fabricação", readonly=True + ) + efrotas_ano_modelo = fields.Integer(string="Ano Modelo", readonly=True) + efrotas_combustivel = fields.Char(string="Combustível", readonly=True) + efrotas_tipo_veiculo = fields.Char(string="Tipo de Veículo", readonly=True) + efrotas_especie = fields.Char(string="Espécie", readonly=True) + efrotas_categoria = fields.Char(string="Categoria", readonly=True) + efrotas_marca_modelo = fields.Char(string="Marca/Modelo", readonly=True) + + # Indicadores e alertas + efrotas_is_associated = fields.Boolean( + string="Associado à Frota", + readonly=True, + help="Indica se o veículo está oficialmente associado ao CNPJ no SENATRAN.", + ) + efrotas_has_theft = fields.Boolean( + string="Alerta Roubo/Furto", + readonly=True, + help="Indica se há registro ativo de furto ou roubo no SENATRAN.", + ) + efrotas_has_recall = fields.Boolean( + string="Recall Pendente", + readonly=True, + help="Indica se há chamado de recall pendente para o veículo.", + ) + efrotas_has_renajud = fields.Boolean( + string="Restrição Judicial (RENAJUD)", + readonly=True, + help="Indica se há restrição judicial cadastrada no RENAJUD.", + ) + efrotas_has_infractions = fields.Boolean( + string="Infrações Exigíveis", + readonly=True, + help="Indica se há infrações de trânsito em aberto.", + ) + efrotas_has_sale_comm = fields.Boolean( + string="Comunicação de Venda", + readonly=True, + help="Indica se há comunicação de venda registrada.", + ) + efrotas_last_status_msg = fields.Char( + string="Mensagem do Status", readonly=True + ) + + # Relacionamento com logs + efrotas_log_ids = fields.One2many( + "efrotas.log", "vehicle_id", string="Logs de Comunicação e-Frotas" + ) + efrotas_log_count = fields.Integer( + string="Total de Logs e-Frotas", + compute="_compute_efrotas_log_count", + ) + + def _compute_efrotas_log_count(self): + for vehicle in self: + vehicle.efrotas_log_count = len(vehicle.efrotas_log_ids) + + def action_view_efrotas_logs(self): + self.ensure_one() + action = self.env.ref("l10_br_fleet_efrotas.action_efrotas_log").read()[0] + action["domain"] = [("vehicle_id", "=", self.id)] + action["context"] = {"default_vehicle_id": self.id} + return action + + def _get_efrotas_config(self): + """Obtém a configuração ativa para o veículo.""" + self.ensure_one() + if self.efrotas_config_id: + return self.efrotas_config_id + config = self.env["efrotas.config"].search( + [ + ("company_id", "=", self.company_id.id or self.env.company.id), + ("active", "=", True), + ], + limit=1, + ) + if not config: + config = self.env["efrotas.config"].search( + [("active", "=", True)], limit=1 + ) + if not config: + raise UserError( + _( + "Nenhuma configuração ativa do e-Frotas encontrada. " + "Por favor, configure o e-Frotas em Frota > Configuração > e-Frotas SENATRAN." + ) + ) + return config + + def _get_efrotas_client(self): + """Obtém o cliente de API configurado para este veículo.""" + config = self._get_efrotas_config() + return config.get_client(vehicle_id=self.id) + + def action_efrotas_sync(self): + """Consulta os dados cadastrais e indicadores do veículo no e-Frotas e atualiza o cadastro.""" + for vehicle in self: + if not vehicle.license_plate: + raise UserError( + _("Informe a placa do veículo antes de consultar o e-Frotas.") + ) + + client = vehicle._get_efrotas_client() + placa = vehicle.license_plate.replace("-", "").strip().upper() + + try: + # 1. Consulta dados cadastrais + data = client.get_veiculo_por_placa(placa) + + # 2. Consulta associação + assoc_data = {} + try: + assoc_data = client.check_veiculo_associado(placa) + except Exception as ex_assoc: + _logger.info( + "Falha ao checar associação para placa %s: %s", + placa, + ex_assoc, + ) + + is_associated = True + if isinstance(assoc_data, dict): + msg = assoc_data.get("mensagem", "") + if "não" in msg.lower() or "nao" in msg.lower(): + is_associated = False + + vals = { + "efrotas_synced": True, + "efrotas_last_sync": fields.Datetime.now(), + "efrotas_is_associated": is_associated, + "efrotas_last_status_msg": _( + "Sincronizado com sucesso via e-Frotas." + ), + } + + if isinstance(data, dict): + if data.get("renavam"): + vals["efrotas_renavam"] = data.get("renavam") + if data.get("chassi"): + vals["efrotas_chassi"] = data.get("chassi") + if not vehicle.vin_sn: + vals["vin_sn"] = data.get("chassi") + if data.get("descricaoMunicipioEmplacamento"): + vals["efrotas_municipio"] = data.get( + "descricaoMunicipioEmplacamento" + ) + if data.get("descricaoCor"): + vals["efrotas_cor"] = data.get("descricaoCor") + if not vehicle.color: + vals["color"] = data.get("descricaoCor") + if data.get("anoFabricacao"): + vals["efrotas_ano_fabricacao"] = int( + data.get("anoFabricacao") + ) + if data.get("anoModelo"): + vals["efrotas_ano_modelo"] = int(data.get("anoModelo")) + if not vehicle.model_year: + vals["model_year"] = str(data.get("anoModelo")) + if data.get("descricaoCombustivel"): + vals["efrotas_combustivel"] = data.get( + "descricaoCombustivel" + ) + if data.get("descricaoTipoVeiculo"): + vals["efrotas_tipo_veiculo"] = data.get( + "descricaoTipoVeiculo" + ) + if data.get("descricaoEspecie"): + vals["efrotas_especie"] = data.get("descricaoEspecie") + if data.get("descricaoCategoria"): + vals["efrotas_categoria"] = data.get( + "descricaoCategoria" + ) + if data.get("descricaoMarcaModelo"): + vals["efrotas_marca_modelo"] = data.get( + "descricaoMarcaModelo" + ) + + # Indicadores + vals["efrotas_has_theft"] = bool( + data.get("indicadorRouboFurto") + or data.get("ocorrenciaRouboFurto") + ) + vals["efrotas_has_recall"] = bool( + data.get("indicadorRecall") + or data.get("ocorrenciaRecall") + ) + vals["efrotas_has_renajud"] = bool( + data.get("indicadorRestricaoJudicial") + or data.get("restricoesJudiciais") + ) + vals["efrotas_has_infractions"] = bool( + data.get("indicadorInfracao") + or data.get("infracoesExigiveis") + ) + vals["efrotas_has_sale_comm"] = bool( + data.get("indicadorComunicacaoVenda") + ) + + vehicle.write(vals) + + except EfrotasException as ex: + vehicle.write( + { + "efrotas_last_status_msg": _( + "Erro na sincronização: %s" + ) + % str(ex) + } + ) + raise UserError( + _("Erro ao consultar e-Frotas para o veículo %s: %s") + % (vehicle.name or vehicle.license_plate, str(ex)) + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("e-Frotas SENATRAN"), + "message": _( + "Veículo(s) sincronizado(s) com sucesso com o e-Frotas!" + ), + "type": "success", + "sticky": False, + }, + } + + def action_efrotas_download_crlv(self): + """Obtém o documento digital CRLV-e da API e anexa como PDF ao veículo.""" + self.ensure_one() + if not self.license_plate: + raise UserError(_("Informe a placa do veículo antes de baixar o CRLV-e.")) + + client = self._get_efrotas_client() + placa = self.license_plate.replace("-", "").strip().upper() + + try: + res = client.get_crlv(placa) + pdf_base64 = res.get("pdfBase64") if isinstance(res, dict) else None + + if not pdf_base64: + raise UserError( + _( + "A resposta do e-Frotas não continha o PDF em base64 do CRLV-e." + ) + ) + + ano = res.get("anoLicenciamento") or res.get("anoExercicio") or "" + filename = f"CRLV-e_{placa}{f'_{ano}' if ano else ''}.pdf" + + attachment = self.env["ir.attachment"].create( + { + "name": filename, + "type": "binary", + "datas": pdf_base64, + "res_model": "fleet.vehicle", + "res_id": self.id, + "mimetype": "application/pdf", + } + ) + + msg = _("CRLV-e baixado com sucesso e anexado ao veículo (%s).") % filename + self.message_post( + body=msg, + attachment_ids=[attachment.id], + ) + + return { + "type": "ir.actions.act_url", + "url": f"/web/content/{attachment.id}?download=true", + "target": "self", + } + except EfrotasException as ex: + raise UserError(_("Erro ao obter CRLV-e no e-Frotas: %s") % str(ex)) + + def action_efrotas_check_association(self): + """Verifica se o veículo está associado ao CNPJ configurado.""" + self.ensure_one() + if not self.license_plate: + raise UserError(_("Informe a placa do veículo.")) + + client = self._get_efrotas_client() + placa = self.license_plate.replace("-", "").strip().upper() + + try: + res = client.check_veiculo_associado(placa) + msg = res.get("mensagem", _("Consulta concluída")) + is_associated = not ( + "não" in msg.lower() or "nao" in msg.lower() + ) + + self.write( + { + "efrotas_is_associated": is_associated, + "efrotas_last_status_msg": msg, + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Associação e-Frotas"), + "message": msg, + "type": "info" if is_associated else "warning", + "sticky": False, + }, + } + except EfrotasException as ex: + raise UserError( + _("Erro ao verificar associação no e-Frotas: %s") % str(ex) + ) diff --git a/l10_br_fleet_efrotas/security/ir.model.access.csv b/l10_br_fleet_efrotas/security/ir.model.access.csv new file mode 100644 index 000000000..5388e5a5e --- /dev/null +++ b/l10_br_fleet_efrotas/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_efrotas_config_user,efrotas.config.user,model_efrotas_config,l10_br_fleet_efrotas.group_efrotas_user,1,0,0,0 +access_efrotas_config_manager,efrotas.config.manager,model_efrotas_config,l10_br_fleet_efrotas.group_efrotas_manager,1,1,1,1 +access_efrotas_log_user,efrotas.log.user,model_efrotas_log,l10_br_fleet_efrotas.group_efrotas_user,1,0,0,0 +access_efrotas_log_manager,efrotas.log.manager,model_efrotas_log,l10_br_fleet_efrotas.group_efrotas_manager,1,1,1,1 diff --git a/l10_br_fleet_efrotas/security/security.xml b/l10_br_fleet_efrotas/security/security.xml new file mode 100644 index 000000000..1a4080d08 --- /dev/null +++ b/l10_br_fleet_efrotas/security/security.xml @@ -0,0 +1,19 @@ + + + + e-Frotas SENATRAN + 25 + + + + Usuário e-Frotas + + + + + + Gestor e-Frotas + + + + diff --git a/l10_br_fleet_efrotas/tests/__init__.py b/l10_br_fleet_efrotas/tests/__init__.py new file mode 100644 index 000000000..26c3feb09 --- /dev/null +++ b/l10_br_fleet_efrotas/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Fleet e-Frotas Contributors +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_efrotas_client diff --git a/l10_br_fleet_efrotas/tests/test_efrotas_client.py b/l10_br_fleet_efrotas/tests/test_efrotas_client.py new file mode 100644 index 000000000..0040e9745 --- /dev/null +++ b/l10_br_fleet_efrotas/tests/test_efrotas_client.py @@ -0,0 +1,220 @@ +# Copyright 2026 Fleet e-Frotas Contributors +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import unittest +from unittest.mock import MagicMock, patch + +from l10_br_fleet_efrotas.models.efrotas_client import ( + EfrotasAuthError, + EfrotasClient, + EfrotasException, + EfrotasNotFoundError, + EfrotasValidationError, +) + + +class TestEfrotasClient(unittest.TestCase): + def setUp(self): + self.base_url = ( + "https://hom-efrotas.np.estaleiro.serpro.gov.br/efrotas/api" + ) + self.token = "sample_jwt_token_12345" + self.client = EfrotasClient(base_url=self.base_url, token=self.token) + + def test_headers_and_auth(self): + """Verifica se os cabeçalhos Authorization e x-token-client são preenchidos corretamente.""" + with patch("requests.request") as mock_req: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.headers = {"Content-Type": "application/json"} + mock_resp.json.return_value = {"placa": "SAV0741"} + mock_req.return_value = mock_resp + + res = self.client.get_veiculo_por_placa("SAV0741") + self.assertEqual(res["placa"], "SAV0741") + + mock_req.assert_called_once() + args, kwargs = mock_req.call_args + headers = kwargs["headers"] + self.assertEqual(headers["Authorization"], "Bearer sample_jwt_token_12345") + self.assertEqual(headers["x-token-client"], "sample_jwt_token_12345") + + def test_get_crlv(self): + """Testa a obtenção do CRLV-e digital.""" + with patch("requests.request") as mock_req: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.headers = {"Content-Type": "application/json"} + mock_resp.json.return_value = { + "pdfBase64": "JVBERi0xLjUK...", + "anoLicenciamento": 2024, + } + mock_req.return_value = mock_resp + + res = self.client.get_crlv("SAV-0741") + self.assertIn("pdfBase64", res) + self.assertEqual(res["anoLicenciamento"], 2024) + + expected_url = f"{self.base_url}/crlv/v1/documento/placa/SAV0741" + self.assertEqual(mock_req.call_args[1]["url"], expected_url) + + def test_consultas_endpoints(self): + """Testa chamadas aos endpoints de consultas (veículo, roubo, recall, renajud, infrações).""" + with patch("requests.request") as mock_req: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.headers = {"Content-Type": "application/json"} + mock_resp.json.return_value = {"status": "ok"} + mock_req.return_value = mock_resp + + # Veículos da frota + self.client.get_veiculos(cnpj_filial="33.683.111/0001-07") + self.assertEqual( + mock_req.call_args[1]["url"], f"{self.base_url}/consultas/v1/veiculos" + ) + self.assertEqual( + mock_req.call_args[1]["params"]["cnpjFilial"], "33683111000107" + ) + + # Roubo e furto + self.client.get_ocorrencias_roubo_furto("SAV0741") + self.assertEqual( + mock_req.call_args[1]["url"], + f"{self.base_url}/consultas/v1/veiculos/placa/SAV0741/ocorrencias-roubo-furto", + ) + + # Recall + self.client.get_recall("SAV0741") + self.assertEqual( + mock_req.call_args[1]["url"], + f"{self.base_url}/consultas/v1/veiculos/placa/SAV0741/recall", + ) + + # RENAJUD + self.client.get_restricoes_renajud( + "SAV0741", tipo_restricao="TRANSFERENCIA" + ) + self.assertEqual( + mock_req.call_args[1]["url"], + f"{self.base_url}/consultas/v1/veiculos/placa/SAV0741/restricoes-renajud", + ) + self.assertEqual( + mock_req.call_args[1]["params"]["tipoRestricao"], "TRANSFERENCIA" + ) + + # Infrações por período + self.client.get_infracoes_veiculo( + "SAV0741", "2024-01-01", "2024-06-01" + ) + self.assertEqual( + mock_req.call_args[1]["url"], + f"{self.base_url}/consultas/v1/infracoes/placa/SAV0741", + ) + + # Detalhes de infração + self.client.get_detalhes_infracao("200010", "AIT123", "50020") + self.assertEqual( + mock_req.call_args[1]["url"], + f"{self.base_url}/consultas/v1/infracoes/codigoOrgao/200010/numeroAit/AIT123/codigoInfracao/50020", + ) + + # PDFs SNE + self.client.get_pdf_notificacao_autuacao( + "SAV0741", "200010", "AIT123", "50020" + ) + self.assertEqual( + mock_req.call_args[1]["url"], + f"{self.base_url}/consultas/sne/pdf/placa/SAV0741/codigoOrgao/200010/numeroAit/AIT123/codigoInfracao/50020/NA", + ) + + def test_autorizador_and_notificacoes(self): + """Testa endpoints de gerenciamento de webhook/eventos e notificações.""" + with patch("requests.request") as mock_req: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.headers = {"Content-Type": "application/json"} + mock_resp.json.return_value = [{"id": 1, "url": "https://meusite.com/hook"}] + mock_req.return_value = mock_resp + + self.client.get_endpoints() + self.assertEqual( + mock_req.call_args[1]["url"], + f"{self.base_url}/autorizador/v1/endpoint", + ) + + self.client.get_notificacoes("01012024", "01062024", lido=False) + self.assertEqual( + mock_req.call_args[1]["url"], + f"{self.base_url}/notificacoes/v1/dataInicio/01012024/dataFim/01062024", + ) + self.assertEqual(mock_req.call_args[1]["params"]["lido"], "false") + + def test_transacional(self): + """Testa endpoints transacionais (boleto, condutor, real infrator).""" + with patch("requests.request") as mock_req: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.headers = {"Content-Type": "application/json"} + mock_resp.json.return_value = {"chaveBoleto": "BOL123"} + mock_req.return_value = mock_resp + + # Solicitar boleto com desconto + self.client.solicitar_boleto("SAV0741", "INF_KEY_123", desconto40=True) + self.assertEqual( + mock_req.call_args[1]["url"], + f"{self.base_url}/transacional/v1/boleto/solicitarBoleto", + ) + self.assertEqual( + mock_req.call_args[1]["params"]["desconto40"], "true" + ) + + # Reconhecer infração + self.client.reconhecer_infracao("SAV0741", "INF_KEY_123") + self.assertEqual( + mock_req.call_args[1]["url"], + f"{self.base_url}/transacional/v1/boleto/reconhecerInfracao", + ) + + def test_error_handling_401(self): + """Valida que status 401 dispara EfrotasAuthError.""" + with patch("requests.request") as mock_req: + mock_resp = MagicMock() + mock_resp.status_code = 401 + mock_resp.text = "Unauthorized token" + mock_resp.json.side_effect = ValueError() + mock_req.return_value = mock_resp + + with self.assertRaises(EfrotasAuthError): + self.client.get_veiculo_por_placa("SAV0741") + + def test_error_handling_404(self): + """Valida que status 404 dispara EfrotasNotFoundError.""" + with patch("requests.request") as mock_req: + mock_resp = MagicMock() + mock_resp.status_code = 404 + mock_resp.text = "Not Found" + mock_resp.json.side_effect = ValueError() + mock_req.return_value = mock_resp + + with self.assertRaises(EfrotasNotFoundError): + self.client.get_veiculo_por_placa("INEXISTENTE") + + def test_error_handling_400(self): + """Valida que status 400 dispara EfrotasValidationError com mensagem descritiva.""" + with patch("requests.request") as mock_req: + mock_resp = MagicMock() + mock_resp.status_code = 400 + mock_resp.headers = {"Content-Type": "application/json"} + mock_resp.json.return_value = { + "mensagem": "Placa informada é inválida" + } + mock_resp.text = '{"mensagem": "Placa informada é inválida"}' + mock_req.return_value = mock_resp + + with self.assertRaises(EfrotasValidationError) as ctx: + self.client.get_veiculo_por_placa("1234") + self.assertIn("Placa informada é inválida", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/l10_br_fleet_efrotas/tests/test_live_serpro_homologation.py b/l10_br_fleet_efrotas/tests/test_live_serpro_homologation.py new file mode 100644 index 000000000..ab9ddf1bd --- /dev/null +++ b/l10_br_fleet_efrotas/tests/test_live_serpro_homologation.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# Copyright 2026 Fleet e-Frotas Contributors +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +"""Script de teste de integração direto com o ambiente de homologação do SERPRO. + +Pode ser executado diretamente com: + python3 test_live_serpro_homologation.py +""" + +import sys +from l10_br_fleet_efrotas.models.efrotas_client import ( + EfrotasClient, + EfrotasException, +) + +HOMOLOGATION_TOKEN = ( + "eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDAxMDY4MDAsImlzcyI6Imh0dHBzOi8vZWZyb3Rh" + "cy5zZXJwcm8uZ292LmJyL2lzc3VlciIsImNucGoiOiIzMzY4MzExMTAwMDEwNyJ9.GnYpQDIZ" + "Qtprkqp3pv1BEUvf3mHAzpIATlgeCoJbuw4" +) +TEST_PLATE = "SAV0741" + + +def run_live_tests(): + print("=== TESTE DE CONEXÃO COM HOMOLOGAÇÃO e-FROTAS SERPRO ===") + client = EfrotasClient( + base_url="https://hom-efrotas.np.estaleiro.serpro.gov.br/efrotas/api", + token=HOMOLOGATION_TOKEN, + timeout=15, + ) + + success_count = 0 + total_count = 0 + + # 1. Consulta Veículo + total_count += 1 + try: + print(f"\n1. Consultando veículo por placa ({TEST_PLATE})...") + v_data = client.get_veiculo_por_placa(TEST_PLATE) + print(" -> SUCESSO! Dados recebidos:") + print(f" Placa: {v_data.get('placa')}") + print(f" Renavam: {v_data.get('renavam')}") + print(f" Marca/Modelo: {v_data.get('descricaoMarcaModelo')}") + print(f" Chassi: {v_data.get('chassi')}") + print(f" Município: {v_data.get('descricaoMunicipioEmplacamento')}") + success_count += 1 + except EfrotasException as ex: + print(f" -> FALHA: {ex}") + + # 2. Consulta Associação + total_count += 1 + try: + print(f"\n2. Verificando se veículo está associado ({TEST_PLATE})...") + assoc_data = client.check_veiculo_associado(TEST_PLATE) + print(f" -> SUCESSO! Mensagem: {assoc_data.get('mensagem')}") + success_count += 1 + except EfrotasException as ex: + print(f" -> FALHA: {ex}") + + # 3. Consulta CRLV-e Digital + total_count += 1 + try: + print(f"\n3. Baixando CRLV-e digital ({TEST_PLATE})...") + crlv_data = client.get_crlv(TEST_PLATE) + pdf_len = len(crlv_data.get("pdfBase64", "")) + print(f" -> SUCESSO! PDF Base64 recebido ({pdf_len} caracteres).") + success_count += 1 + except EfrotasException as ex: + print(f" -> FALHA: {ex}") + + # 4. Lista de Veículos do CNPJ + total_count += 1 + try: + print("\n4. Consultando lista de veículos do CNPJ...") + frota = client.get_veiculos(quantidade=5) + print( + f" -> SUCESSO! {len(frota)} veículos retornados na primeira página." + ) + for v in frota[:3]: + print( + f" - Placa: {v.get('placa')} | Modelo: {v.get('descricaoMarcaModelo')}" + ) + success_count += 1 + except EfrotasException as ex: + print(f" -> FALHA: {ex}") + + print("\n" + "=" * 55) + print( + f"RESULTADO: {success_count}/{total_count} testes passaram com sucesso!" + ) + print("=" * 55) + return success_count == total_count + + +if __name__ == "__main__": + success = run_live_tests() + sys.exit(0 if success else 1) diff --git a/l10_br_fleet_efrotas/views/efrotas_config_views.xml b/l10_br_fleet_efrotas/views/efrotas_config_views.xml new file mode 100644 index 000000000..9c4735bb0 --- /dev/null +++ b/l10_br_fleet_efrotas/views/efrotas_config_views.xml @@ -0,0 +1,96 @@ + + + + + efrotas.config.form + efrotas.config + +
+
+
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + efrotas.config.tree + efrotas.config + + + + + + + + + + + + + + + + + + Configuração e-Frotas + efrotas.config + tree,form + +

+ Cadastre as credenciais e parâmetros de conexão com o e-Frotas SENATRAN! +

+
+
+
diff --git a/l10_br_fleet_efrotas/views/efrotas_log_views.xml b/l10_br_fleet_efrotas/views/efrotas_log_views.xml new file mode 100644 index 000000000..51db4ad63 --- /dev/null +++ b/l10_br_fleet_efrotas/views/efrotas_log_views.xml @@ -0,0 +1,81 @@ + + + + + efrotas.log.form + efrotas.log + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + efrotas.log.tree + efrotas.log + + + + + + + + + + + + + + + + efrotas.log.search + efrotas.log + + + + + + + + + + + + + + + + + + Logs e-Frotas + efrotas.log + tree,form + + +
diff --git a/l10_br_fleet_efrotas/views/efrotas_menus.xml b/l10_br_fleet_efrotas/views/efrotas_menus.xml new file mode 100644 index 000000000..476c12a1a --- /dev/null +++ b/l10_br_fleet_efrotas/views/efrotas_menus.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/l10_br_fleet_efrotas/views/efrotas_query_wizard_views.xml b/l10_br_fleet_efrotas/views/efrotas_query_wizard_views.xml new file mode 100644 index 000000000..dd51bd9ae --- /dev/null +++ b/l10_br_fleet_efrotas/views/efrotas_query_wizard_views.xml @@ -0,0 +1,59 @@ + + + + efrotas.query.wizard.form + efrotas.query.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Consulta e-Frotas + efrotas.query.wizard + form + new + +
diff --git a/l10_br_fleet_efrotas/views/fleet_vehicle_views.xml b/l10_br_fleet_efrotas/views/fleet_vehicle_views.xml new file mode 100644 index 000000000..0b4a06821 --- /dev/null +++ b/l10_br_fleet_efrotas/views/fleet_vehicle_views.xml @@ -0,0 +1,110 @@ + + + + fleet.vehicle.form.efrotas + fleet.vehicle + + + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + fleet.vehicle.search.efrotas + fleet.vehicle + + + + + + + + + + + + + + + + + +
diff --git a/l10_br_fleet_efrotas/wizard/__init__.py b/l10_br_fleet_efrotas/wizard/__init__.py new file mode 100644 index 000000000..10d90ee3a --- /dev/null +++ b/l10_br_fleet_efrotas/wizard/__init__.py @@ -0,0 +1,9 @@ +# Copyright 2026 Fleet e-Frotas Contributors +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +try: + import odoo + from . import efrotas_query_wizard +except ImportError: + pass + diff --git a/l10_br_fleet_efrotas/wizard/efrotas_query_wizard.py b/l10_br_fleet_efrotas/wizard/efrotas_query_wizard.py new file mode 100644 index 000000000..a80e46b87 --- /dev/null +++ b/l10_br_fleet_efrotas/wizard/efrotas_query_wizard.py @@ -0,0 +1,167 @@ +# Copyright 2026 Fleet e-Frotas Contributors +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +from datetime import date +from dateutil.relativedelta import relativedelta +from odoo import _, fields, models +from odoo.exceptions import UserError +from ..models.efrotas_client import EfrotasException + + +class EfrotasQueryWizard(models.TransientModel): + _name = "efrotas.query.wizard" + _description = "Assistente de Consultas e-Frotas SENATRAN" + + config_id = fields.Many2one( + "efrotas.config", + string="Configuração e-Frotas", + required=True, + default=lambda self: self.env["efrotas.config"].search( + [("active", "=", True)], limit=1 + ), + ) + query_type = fields.Selection( + [ + ("veiculo", "Dados Cadastrais do Veículo"), + ("associado", "Verificar Associação à Frota"), + ("crlv", "Documento Digital (CRLV-e)"), + ("infracoes", "Infrações por Período"), + ("infracao_detalhe", "Detalhe de Infração por Chave"), + ("roubo_furto", "Histórico de Roubo / Furto"), + ("recall", "Registros de Recall Pendente"), + ("renajud", "Restrições Judiciais (RENAJUD)"), + ("notificacoes", "Notificações / Eventos Recebidos"), + ], + string="Tipo de Consulta", + default="veiculo", + required=True, + ) + license_plate = fields.Char( + string="Placa", + help="Placa do veículo a consultar (ex: SAV0741)", + ) + date_start = fields.Date( + string="Data Inicial", + default=lambda self: date.today() - relativedelta(months=1), + ) + date_end = fields.Date( + string="Data Final", + default=lambda self: date.today(), + ) + codigo_orgao = fields.Char(string="Código do Órgão Autuador") + numero_ait = fields.Char(string="Número do AIT") + codigo_infracao = fields.Char(string="Código da Infração") + tipo_restricao = fields.Char( + string="Tipo de Restrição RENAJUD", + help="Filtro opcional para restrições judiciais", + ) + result_text = fields.Text(string="Resultado JSON", readonly=True) + pdf_attachment_id = fields.Many2one( + "ir.attachment", string="Arquivo PDF Gerado", readonly=True + ) + + def action_execute_query(self): + """Executa a consulta selecionada utilizando o EfrotasClient.""" + self.ensure_one() + if not self.config_id: + raise UserError(_("Selecione uma configuração do e-Frotas.")) + + client = self.config_id.get_client() + placa = ( + self.license_plate.replace("-", "").strip().upper() + if self.license_plate + else "" + ) + + try: + res_data = None + + if self.query_type == "veiculo": + if not placa: + raise UserError(_("Informe a placa do veículo.")) + res_data = client.get_veiculo_por_placa(placa) + + elif self.query_type == "associado": + if not placa: + raise UserError(_("Informe a placa do veículo.")) + res_data = client.check_veiculo_associado(placa) + + elif self.query_type == "crlv": + if not placa: + raise UserError(_("Informe a placa do veículo.")) + res_data = client.get_crlv(placa) + if isinstance(res_data, dict) and res_data.get("pdfBase64"): + att = self.env["ir.attachment"].create( + { + "name": f"CRLV-e_{placa}.pdf", + "type": "binary", + "datas": res_data.get("pdfBase64"), + "mimetype": "application/pdf", + } + ) + self.pdf_attachment_id = att.id + + elif self.query_type == "infracoes": + if not placa: + raise UserError(_("Informe a placa do veículo.")) + d_init = self.date_start.strftime("%Y-%m-%d") + d_end = self.date_end.strftime("%Y-%m-%d") + res_data = client.get_infracoes_veiculo(placa, d_init, d_end) + + elif self.query_type == "infracao_detalhe": + if not ( + self.codigo_orgao + and self.numero_ait + and self.codigo_infracao + ): + raise UserError( + _( + "Preencha Código do Órgão, Número do AIT e Código da Infração." + ) + ) + res_data = client.get_detalhes_infracao( + self.codigo_orgao.strip(), + self.numero_ait.strip(), + self.codigo_infracao.strip(), + ) + + elif self.query_type == "roubo_furto": + if not placa: + raise UserError(_("Informe a placa do veículo.")) + res_data = client.get_ocorrencias_roubo_furto(placa) + + elif self.query_type == "recall": + if not placa: + raise UserError(_("Informe a placa do veículo.")) + res_data = client.get_recall(placa) + + elif self.query_type == "renajud": + if not placa: + raise UserError(_("Informe a placa do veículo.")) + res_data = client.get_restricoes_renajud( + placa, tipo_restricao=self.tipo_restricao or None + ) + + elif self.query_type == "notificacoes": + d_init = self.date_start.strftime("%d%m%Y") + d_end = self.date_end.strftime("%d%m%Y") + res_data = client.get_notificacoes(d_init, d_end) + + formatted_result = ( + json.dumps(res_data, indent=2, ensure_ascii=False) + if isinstance(res_data, (dict, list)) + else str(res_data) + ) + self.result_text = formatted_result + + return { + "type": "ir.actions.act_window", + "res_model": "efrotas.query.wizard", + "res_id": self.id, + "view_mode": "form", + "target": "new", + } + + except EfrotasException as ex: + raise UserError(_("Erro na consulta e-Frotas: %s") % str(ex)) diff --git a/setup/l10_br_fleet_efrotas/odoo/addons/l10_br_fleet_efrotas b/setup/l10_br_fleet_efrotas/odoo/addons/l10_br_fleet_efrotas new file mode 120000 index 000000000..28717b2eb --- /dev/null +++ b/setup/l10_br_fleet_efrotas/odoo/addons/l10_br_fleet_efrotas @@ -0,0 +1 @@ +../../../../l10_br_fleet_efrotas \ No newline at end of file diff --git a/setup/l10_br_fleet_efrotas/setup.py b/setup/l10_br_fleet_efrotas/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/l10_br_fleet_efrotas/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From dbe53bd009090bc009b572c7c6e9d6718e2e7e1b Mon Sep 17 00:00:00 2001 From: RPSJR Date: Mon, 31 Aug 2026 17:36:30 -0300 Subject: [PATCH 02/11] [fix] efrotas connect --- l10_br_fleet_efrotas/models/efrotas_client.py | 17 +++++++++++++---- scratch_rdc | 1 + 2 files changed, 14 insertions(+), 4 deletions(-) create mode 160000 scratch_rdc diff --git a/l10_br_fleet_efrotas/models/efrotas_client.py b/l10_br_fleet_efrotas/models/efrotas_client.py index 3df418472..de2adf600 100644 --- a/l10_br_fleet_efrotas/models/efrotas_client.py +++ b/l10_br_fleet_efrotas/models/efrotas_client.py @@ -105,11 +105,20 @@ def _get_cert_files(self): if self.certificate_password else None ) - private_key, certificate, additional_certificates = ( - pkcs12.load_key_and_certificates( - self.certificate_data, pwd_bytes + try: + from cryptography.hazmat.backends import default_backend + + private_key, certificate, additional_certificates = ( + pkcs12.load_key_and_certificates( + self.certificate_data, pwd_bytes, default_backend() + ) + ) + except (TypeError, ImportError): + private_key, certificate, additional_certificates = ( + pkcs12.load_key_and_certificates( + self.certificate_data, pwd_bytes + ) ) - ) cert_pem = certificate.public_bytes(Encoding.PEM) if additional_certificates: diff --git a/scratch_rdc b/scratch_rdc new file mode 160000 index 000000000..919e67357 --- /dev/null +++ b/scratch_rdc @@ -0,0 +1 @@ +Subproject commit 919e67357c46d802e34b3e5dbc353e766cb2f1d1 From 6ce240457bb1760ecec7998a5819e50f53c95339 Mon Sep 17 00:00:00 2001 From: RPSJR Date: Tue, 1 Sep 2026 07:18:38 -0300 Subject: [PATCH 03/11] [fix] cryptography --- l10_br_fleet_efrotas/models/efrotas_client.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/l10_br_fleet_efrotas/models/efrotas_client.py b/l10_br_fleet_efrotas/models/efrotas_client.py index de2adf600..3e41067f7 100644 --- a/l10_br_fleet_efrotas/models/efrotas_client.py +++ b/l10_br_fleet_efrotas/models/efrotas_client.py @@ -100,23 +100,33 @@ def _get_cert_files(self): pkcs12, ) + import base64 + + cert_data = self.certificate_data + if isinstance(cert_data, str): + try: + cert_data = base64.b64decode(cert_data) + except Exception: + cert_data = cert_data.encode("latin1") + pwd_bytes = ( self.certificate_password.encode("utf-8") - if self.certificate_password - else None + if isinstance(self.certificate_password, str) + else self.certificate_password ) + try: from cryptography.hazmat.backends import default_backend private_key, certificate, additional_certificates = ( pkcs12.load_key_and_certificates( - self.certificate_data, pwd_bytes, default_backend() + cert_data, pwd_bytes, default_backend() ) ) - except (TypeError, ImportError): + except TypeError: private_key, certificate, additional_certificates = ( pkcs12.load_key_and_certificates( - self.certificate_data, pwd_bytes + cert_data, pwd_bytes ) ) From 24cffca666e078ff6ecfafe49ce2dbcc31e66071 Mon Sep 17 00:00:00 2001 From: RPSJR Date: Tue, 1 Sep 2026 07:26:40 -0300 Subject: [PATCH 04/11] [fix] cripto --- l10_br_fleet_efrotas/models/efrotas_client.py | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/l10_br_fleet_efrotas/models/efrotas_client.py b/l10_br_fleet_efrotas/models/efrotas_client.py index 3e41067f7..078e6b89e 100644 --- a/l10_br_fleet_efrotas/models/efrotas_client.py +++ b/l10_br_fleet_efrotas/models/efrotas_client.py @@ -109,25 +109,28 @@ def _get_cert_files(self): except Exception: cert_data = cert_data.encode("latin1") - pwd_bytes = ( - self.certificate_password.encode("utf-8") - if isinstance(self.certificate_password, str) - else self.certificate_password + pwd_bytes = None + if self.certificate_password: + if isinstance(self.certificate_password, str): + pwd_bytes = self.certificate_password.encode("utf-8") + elif isinstance(self.certificate_password, bytes): + pwd_bytes = self.certificate_password + + from cryptography.hazmat.backends import default_backend + + private_key, certificate, additional_certificates = ( + pkcs12.load_key_and_certificates( + cert_data, pwd_bytes, default_backend() + ) ) - try: - from cryptography.hazmat.backends import default_backend - - private_key, certificate, additional_certificates = ( - pkcs12.load_key_and_certificates( - cert_data, pwd_bytes, default_backend() - ) + if not certificate: + raise EfrotasAuthError( + "O arquivo PKCS#12 (.pfx/.p12) não contém um certificado público válido." ) - except TypeError: - private_key, certificate, additional_certificates = ( - pkcs12.load_key_and_certificates( - cert_data, pwd_bytes - ) + if not private_key: + raise EfrotasAuthError( + "O arquivo PKCS#12 (.pfx/.p12) não contém uma chave privada válida." ) cert_pem = certificate.public_bytes(Encoding.PEM) From 6da61773dc822e5bd88789e9943d83461ec00724 Mon Sep 17 00:00:00 2001 From: RPSJR Date: Tue, 1 Sep 2026 07:32:14 -0300 Subject: [PATCH 05/11] [fix] _name_search return record IDs and include license_plate --- fleet_vehicle_license_plate_alt/models/fleet_vehicle.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fleet_vehicle_license_plate_alt/models/fleet_vehicle.py b/fleet_vehicle_license_plate_alt/models/fleet_vehicle.py index 261e85a9f..8cf5a2462 100644 --- a/fleet_vehicle_license_plate_alt/models/fleet_vehicle.py +++ b/fleet_vehicle_license_plate_alt/models/fleet_vehicle.py @@ -23,13 +23,14 @@ def _name_search( domain = [] else: domain = [ + "|", "|", "|", ("name", operator, name), + ("license_plate", operator, name), ("driver_id.name", operator, name), ("license_plate_alt", operator, name), ] - rec = self._search( + return self._search( expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid ) - return models.lazy_name_get(self.browse(rec).with_user(name_get_uid)) From 956c80f44db991afc39d043aa2a14853c5f639c0 Mon Sep 17 00:00:00 2001 From: RPSJR Date: Tue, 1 Sep 2026 07:54:15 -0300 Subject: [PATCH 06/11] [upd] version --- l10_br_fleet_efrotas/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/l10_br_fleet_efrotas/__manifest__.py b/l10_br_fleet_efrotas/__manifest__.py index e75df1588..8944398b1 100644 --- a/l10_br_fleet_efrotas/__manifest__.py +++ b/l10_br_fleet_efrotas/__manifest__.py @@ -8,7 +8,7 @@ Consultas de veículos, CRLV-e digital, infrações, restrições RENAJUD, roubo/furto, recall, webhooks e transações (indicação de condutor, boletos). """, - "version": "13.0.1.0.0", + "version": "13.0.1.1.0", "category": "Human Resources/Fleet", "author": "Fleet Contributors, Odoo Community Association (OCA)", "website": "https://github.com/OCA/fleet", From 72ac500c797f71c5cd0532a5a75622a2a5fe7933 Mon Sep 17 00:00:00 2001 From: RPSJR Date: Tue, 1 Sep 2026 08:49:31 -0300 Subject: [PATCH 07/11] [fix] production endpoint --- l10_br_fleet_efrotas/models/efrotas_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/l10_br_fleet_efrotas/models/efrotas_client.py b/l10_br_fleet_efrotas/models/efrotas_client.py index 078e6b89e..42c38deb8 100644 --- a/l10_br_fleet_efrotas/models/efrotas_client.py +++ b/l10_br_fleet_efrotas/models/efrotas_client.py @@ -44,7 +44,9 @@ class EfrotasClient: DEFAULT_HOMOLOGATION_URL = ( "https://hom-efrotas.np.estaleiro.serpro.gov.br/efrotas/api" ) - DEFAULT_PRODUCTION_URL = "https://efrotas.serpro.gov.br/efrotas/api" + DEFAULT_PRODUCTION_URL = ( + "https://efrotas.estaleiro.serpro.gov.br/efrotas/api" + ) def __init__( self, From 7ff4b81462b631e120c1197c0b44dec138c27c0f Mon Sep 17 00:00:00 2001 From: RPSJR Date: Tue, 1 Sep 2026 08:53:12 -0300 Subject: [PATCH 08/11] [fix] production connection test --- l10_br_fleet_efrotas/__manifest__.py | 2 +- l10_br_fleet_efrotas/models/efrotas_config.py | 34 +++++++++++++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/l10_br_fleet_efrotas/__manifest__.py b/l10_br_fleet_efrotas/__manifest__.py index 8944398b1..32c950140 100644 --- a/l10_br_fleet_efrotas/__manifest__.py +++ b/l10_br_fleet_efrotas/__manifest__.py @@ -8,7 +8,7 @@ Consultas de veículos, CRLV-e digital, infrações, restrições RENAJUD, roubo/furto, recall, webhooks e transações (indicação de condutor, boletos). """, - "version": "13.0.1.1.0", + "version": "13.0.1.1.1", "category": "Human Resources/Fleet", "author": "Fleet Contributors, Odoo Community Association (OCA)", "website": "https://github.com/OCA/fleet", diff --git a/l10_br_fleet_efrotas/models/efrotas_config.py b/l10_br_fleet_efrotas/models/efrotas_config.py index 7bcfdeddc..693ab71dc 100644 --- a/l10_br_fleet_efrotas/models/efrotas_config.py +++ b/l10_br_fleet_efrotas/models/efrotas_config.py @@ -186,16 +186,30 @@ def action_test_connection(self): client = self.get_client() try: - # Em homologação, testa consultando a frota ou a placa de teste SAV0741 - test_plate = "SAV0741" - res = client.get_veiculo_por_placa(test_plate) - placa_retornada = ( - res.get("placa") if isinstance(res, dict) else str(res) - ) - - msg = _("Conexão estabelecida com sucesso! Veículo de teste: %s") % ( - placa_retornada or test_plate - ) + if self.environment == "homologation": + test_plate = "SAV0741" + res = client.get_veiculo_por_placa(test_plate) + placa_retornada = ( + res.get("placa") if isinstance(res, dict) else str(res) + ) + msg = _( + "Conexão estabelecida com sucesso em Homologação! Veículo de teste: %s" + ) % (placa_retornada or test_plate) + else: + # Em produção, consulta a frota vinculada ao CNPJ do certificado + res = client.get_veiculos( + cnpj_filial=self.cnpj_filial, pagina=1, quantidade=1 + ) + total = 0 + if isinstance(res, dict): + total = ( + res.get("totalRegistros") + or res.get("totalElementos") + or len(res.get("veiculos", [])) + ) + msg = _( + "Conexão estabelecida com sucesso em Produção com o certificado digital! (Veículos na frota: %s)" + ) % total self.write( { "last_test_date": fields.Datetime.now(), From dce95405598f3ec411b7d82eab5e99aa621457c8 Mon Sep 17 00:00:00 2001 From: RPSJR Date: Tue, 1 Sep 2026 09:23:16 -0300 Subject: [PATCH 09/11] [fix] reload view and persist test status on connection test --- l10_br_fleet_efrotas/__manifest__.py | 2 +- l10_br_fleet_efrotas/models/efrotas_config.py | 38 +++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/l10_br_fleet_efrotas/__manifest__.py b/l10_br_fleet_efrotas/__manifest__.py index 32c950140..e28e8fe0a 100644 --- a/l10_br_fleet_efrotas/__manifest__.py +++ b/l10_br_fleet_efrotas/__manifest__.py @@ -8,7 +8,7 @@ Consultas de veículos, CRLV-e digital, infrações, restrições RENAJUD, roubo/furto, recall, webhooks e transações (indicação de condutor, boletos). """, - "version": "13.0.1.1.1", + "version": "13.0.1.1.2", "category": "Human Resources/Fleet", "author": "Fleet Contributors, Odoo Community Association (OCA)", "website": "https://github.com/OCA/fleet", diff --git a/l10_br_fleet_efrotas/models/efrotas_config.py b/l10_br_fleet_efrotas/models/efrotas_config.py index 693ab71dc..d9ad6e0d8 100644 --- a/l10_br_fleet_efrotas/models/efrotas_config.py +++ b/l10_br_fleet_efrotas/models/efrotas_config.py @@ -94,7 +94,7 @@ class EfrotasConfig(models.Model): last_test_date = fields.Datetime( string="Último Teste de Conexão", readonly=True ) - last_test_result = fields.Char( + last_test_result = fields.Text( string="Resultado do Último Teste", readonly=True ) last_test_status = fields.Selection( @@ -225,10 +225,15 @@ def action_test_connection(self): "message": msg, "type": "success", "sticky": False, + "next": { + "type": "ir.actions.client", + "tag": "reload", + }, }, } except EfrotasException as ex: msg = _("Falha na conexão com e-Frotas: %s") % str(ex) + _logger.warning("Falha no teste de conexão e-Frotas: %s", msg) self.write( { "last_test_date": fields.Datetime.now(), @@ -236,9 +241,23 @@ def action_test_connection(self): "last_test_status": "failed", } ) - raise UserError(msg) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Falha no Teste de Conexão"), + "message": msg, + "type": "danger", + "sticky": True, + "next": { + "type": "ir.actions.client", + "tag": "reload", + }, + }, + } except Exception as ex: msg = _("Erro inesperado ao testar conexão: %s") % str(ex) + _logger.exception("Erro inesperado no teste de conexão e-Frotas: %s", msg) self.write( { "last_test_date": fields.Datetime.now(), @@ -246,4 +265,17 @@ def action_test_connection(self): "last_test_status": "failed", } ) - raise UserError(msg) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Erro no Teste de Conexão"), + "message": msg, + "type": "danger", + "sticky": True, + "next": { + "type": "ir.actions.client", + "tag": "reload", + }, + }, + } From 2d5f4de22aadf59323477a62f7b25a3cb8838fe3 Mon Sep 17 00:00:00 2001 From: RPSJR Date: Tue, 1 Sep 2026 09:25:57 -0300 Subject: [PATCH 10/11] [fix] extract vehicle count and sample plates from production response --- l10_br_fleet_efrotas/__manifest__.py | 2 +- l10_br_fleet_efrotas/models/efrotas_config.py | 56 +++++++++++++++++-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/l10_br_fleet_efrotas/__manifest__.py b/l10_br_fleet_efrotas/__manifest__.py index e28e8fe0a..c2f6019d3 100644 --- a/l10_br_fleet_efrotas/__manifest__.py +++ b/l10_br_fleet_efrotas/__manifest__.py @@ -8,7 +8,7 @@ Consultas de veículos, CRLV-e digital, infrações, restrições RENAJUD, roubo/furto, recall, webhooks e transações (indicação de condutor, boletos). """, - "version": "13.0.1.1.2", + "version": "13.0.1.1.3", "category": "Human Resources/Fleet", "author": "Fleet Contributors, Odoo Community Association (OCA)", "website": "https://github.com/OCA/fleet", diff --git a/l10_br_fleet_efrotas/models/efrotas_config.py b/l10_br_fleet_efrotas/models/efrotas_config.py index d9ad6e0d8..93734cb99 100644 --- a/l10_br_fleet_efrotas/models/efrotas_config.py +++ b/l10_br_fleet_efrotas/models/efrotas_config.py @@ -198,18 +198,64 @@ def action_test_connection(self): else: # Em produção, consulta a frota vinculada ao CNPJ do certificado res = client.get_veiculos( - cnpj_filial=self.cnpj_filial, pagina=1, quantidade=1 + cnpj_filial=self.cnpj_filial, pagina=1, quantidade=20 ) total = 0 - if isinstance(res, dict): + sample_plates = [] + + if isinstance(res, list): + total = len(res) + sample_plates = [ + v.get("placa") + for v in res + if isinstance(v, dict) and v.get("placa") + ] + elif isinstance(res, dict): + items = ( + res.get("veiculos") + or res.get("content") + or res.get("itens") + or res.get("dados") + or res.get("frota") + or [] + ) + if isinstance(items, list): + sample_plates = [ + v.get("placa") + for v in items + if isinstance(v, dict) and v.get("placa") + ] + total = ( res.get("totalRegistros") or res.get("totalElementos") - or len(res.get("veiculos", [])) + or res.get("totalElements") + or res.get("total") + or res.get("count") + or res.get("total_registros") + or res.get("qtdRegistros") + or res.get("quantidade") + or ( + isinstance(res.get("page"), dict) + and res["page"].get("totalElements") + ) + or ( + isinstance(res.get("paginacao"), dict) + and res["paginacao"].get("totalRegistros") + ) + or len(items) ) + + details = f"Veículos encontrados: {total}" + if sample_plates: + plates_str = ", ".join(sample_plates[:5]) + if len(sample_plates) > 5: + plates_str += f" (+{len(sample_plates) - 5})" + details += f" | Placas de amostra: {plates_str}" + msg = _( - "Conexão estabelecida com sucesso em Produção com o certificado digital! (Veículos na frota: %s)" - ) % total + "Conexão estabelecida com sucesso em Produção com o certificado digital! (%s)" + ) % details self.write( { "last_test_date": fields.Datetime.now(), From 6c2e63c23ca95995fa5eb4cb2732c6e7f6ccbfee Mon Sep 17 00:00:00 2001 From: RPSJR Date: Tue, 1 Sep 2026 09:37:14 -0300 Subject: [PATCH 11/11] [fix] reload view and persist error status on fleet vehicle actions --- l10_br_fleet_efrotas/__manifest__.py | 2 +- l10_br_fleet_efrotas/models/fleet_vehicle.py | 95 ++++++++++++++++---- 2 files changed, 81 insertions(+), 16 deletions(-) diff --git a/l10_br_fleet_efrotas/__manifest__.py b/l10_br_fleet_efrotas/__manifest__.py index c2f6019d3..d16656028 100644 --- a/l10_br_fleet_efrotas/__manifest__.py +++ b/l10_br_fleet_efrotas/__manifest__.py @@ -8,7 +8,7 @@ Consultas de veículos, CRLV-e digital, infrações, restrições RENAJUD, roubo/furto, recall, webhooks e transações (indicação de condutor, boletos). """, - "version": "13.0.1.1.3", + "version": "13.0.1.1.4", "category": "Human Resources/Fleet", "author": "Fleet Contributors, Odoo Community Association (OCA)", "website": "https://github.com/OCA/fleet", diff --git a/l10_br_fleet_efrotas/models/fleet_vehicle.py b/l10_br_fleet_efrotas/models/fleet_vehicle.py index 8ae68d338..1bdedd4a4 100644 --- a/l10_br_fleet_efrotas/models/fleet_vehicle.py +++ b/l10_br_fleet_efrotas/models/fleet_vehicle.py @@ -233,18 +233,43 @@ def action_efrotas_sync(self): vehicle.write(vals) except EfrotasException as ex: - vehicle.write( - { - "efrotas_last_status_msg": _( - "Erro na sincronização: %s" - ) - % str(ex) - } - ) - raise UserError( - _("Erro ao consultar e-Frotas para o veículo %s: %s") - % (vehicle.name or vehicle.license_plate, str(ex)) - ) + msg = _("Falha na sincronização com e-Frotas: %s") % str(ex) + _logger.warning("Erro ao sincronizar veículo %s: %s", vehicle.license_plate, msg) + vehicle.write({"efrotas_last_status_msg": msg}) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Falha na Sincronização"), + "message": _("Erro ao consultar e-Frotas para o veículo %s: %s") + % (vehicle.name or vehicle.license_plate, str(ex)), + "type": "danger", + "sticky": True, + "next": { + "type": "ir.actions.client", + "tag": "reload", + }, + }, + } + except Exception as ex: + msg = _("Erro inesperado na sincronização: %s") % str(ex) + _logger.exception("Erro inesperado ao sincronizar veículo %s: %s", vehicle.license_plate, msg) + vehicle.write({"efrotas_last_status_msg": msg}) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Erro na Sincronização"), + "message": _("Erro inesperado para o veículo %s: %s") + % (vehicle.name or vehicle.license_plate, str(ex)), + "type": "danger", + "sticky": True, + "next": { + "type": "ir.actions.client", + "tag": "reload", + }, + }, + } return { "type": "ir.actions.client", @@ -256,6 +281,10 @@ def action_efrotas_sync(self): ), "type": "success", "sticky": False, + "next": { + "type": "ir.actions.client", + "tag": "reload", + }, }, } @@ -338,9 +367,45 @@ def action_efrotas_check_association(self): "message": msg, "type": "info" if is_associated else "warning", "sticky": False, + "next": { + "type": "ir.actions.client", + "tag": "reload", + }, }, } except EfrotasException as ex: - raise UserError( - _("Erro ao verificar associação no e-Frotas: %s") % str(ex) - ) + msg = _("Erro ao verificar associação no e-Frotas: %s") % str(ex) + _logger.warning("Erro ao checar associação placa %s: %s", placa, msg) + self.write({"efrotas_last_status_msg": msg}) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Erro na Associação"), + "message": msg, + "type": "danger", + "sticky": True, + "next": { + "type": "ir.actions.client", + "tag": "reload", + }, + }, + } + except Exception as ex: + msg = _("Erro inesperado ao verificar associação: %s") % str(ex) + _logger.exception("Erro inesperado ao checar associação placa %s: %s", placa, msg) + self.write({"efrotas_last_status_msg": msg}) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Erro na Associação"), + "message": msg, + "type": "danger", + "sticky": True, + "next": { + "type": "ir.actions.client", + "tag": "reload", + }, + }, + }