diff --git a/i18n_ao_nif_validator/CHANGELOG.md b/i18n_ao_nif_validator/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/i18n_ao_nif_validator/README.md b/i18n_ao_nif_validator/README.md new file mode 100644 index 0000000..e69de29 diff --git a/i18n_ao_nif_validator/__init__.py b/i18n_ao_nif_validator/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/i18n_ao_nif_validator/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/i18n_ao_nif_validator/__manifest__.py b/i18n_ao_nif_validator/__manifest__.py new file mode 100644 index 0000000..33e73d0 --- /dev/null +++ b/i18n_ao_nif_validator/__manifest__.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Angola NIF Validator (AGT)', + 'author': 'Hilari Tech LDA', + 'version': '18.0.1.0.0', + 'category': 'Accounting/Localizations', + 'summary': 'Validação automática do NIF angolano via API da AGT', + 'description': """ +Angola NIF Validator (AGT) +========================== +Integração com o endpoint oficial da AGT para validação de NIF. +""", + 'license': 'LGPL-3', + 'depends': [ + 'base', + 'contacts', + 'mail', + 'base_setup', # ADICIONADO: Necessário para as Configurações Gerais + ], + 'data': [ + 'security/nif_validator_security.xml', + 'security/ir.model.access.csv', + 'data/nif_validator_data.xml', + 'views/nif_log_view.xml', # Recomendado carregar o log antes do parceiro se houver campos relacionais + 'views/res_partner_view.xml', + 'views/res_config_settings_view.xml', + 'views/menu.xml', + # 'data/nif_validator_rules.xml', # Certifica-te que este ficheiro existe ou comenta se estiver vazio + ], + 'assets': { + 'web.assets_backend': [ + 'l10n_ao_nif_validator/static/src/css/nif_validator.css', + 'l10n_ao_nif_validator/static/src/js/nif_validator.js', + ], + }, + 'images': ['static/description/icon.png'], + 'installable': True, + 'application': True, # Podes colocar True se quiseres que apareça na lista principal de Apps + 'auto_install': False, +} \ No newline at end of file diff --git a/i18n_ao_nif_validator/__pycache__/__init__.cpython-313.pyc b/i18n_ao_nif_validator/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..e9958fc Binary files /dev/null and b/i18n_ao_nif_validator/__pycache__/__init__.cpython-313.pyc differ diff --git a/i18n_ao_nif_validator/controllers/__init__.py b/i18n_ao_nif_validator/controllers/__init__.py new file mode 100644 index 0000000..cd4d6a8 --- /dev/null +++ b/i18n_ao_nif_validator/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import main \ No newline at end of file diff --git a/i18n_ao_nif_validator/controllers/__pycache__/__init__.cpython-313.pyc b/i18n_ao_nif_validator/controllers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..4cb991e Binary files /dev/null and b/i18n_ao_nif_validator/controllers/__pycache__/__init__.cpython-313.pyc differ diff --git a/i18n_ao_nif_validator/controllers/__pycache__/main.cpython-313.pyc b/i18n_ao_nif_validator/controllers/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..bffbfa9 Binary files /dev/null and b/i18n_ao_nif_validator/controllers/__pycache__/main.cpython-313.pyc differ diff --git a/i18n_ao_nif_validator/controllers/main.py b/i18n_ao_nif_validator/controllers/main.py new file mode 100644 index 0000000..da80047 --- /dev/null +++ b/i18n_ao_nif_validator/controllers/main.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +import logging +from odoo import http, _, fields +from odoo.http import request +from odoo.exceptions import ValidationError, UserError + +_logger = logging.getLogger(__name__) + + +class NifValidatorController(http.Controller): + _ROUTE = '/web/nif/validate' + + @http.route(_ROUTE, type='json', auth='user', methods=['POST'], csrf=True) + def validate_nif(self, nif=None, partner_id=None, **kwargs): + if not nif: + return self._error(_('O campo NIF é obrigatório.')) + + nif = str(nif).strip() + _logger.info('NIF Validator: requisição recebida | nif=%s | partner_id=%s', nif, partner_id) + + try: + partner = self._resolve_partner(partner_id) + result = self._run_validation(partner, nif) + return result + except (ValidationError, UserError) as exc: + return self._error(str(exc.args[0])) + except Exception as exc: + _logger.exception('NIF Validator: erro inesperado') + return self._error(_('Ocorreu um erro interno. Contacte o administrador.')) + + def _resolve_partner(self, partner_id): + Partner = request.env['res.partner'] + if not partner_id: + return Partner.browse([]) + try: + partner_id = int(partner_id) + except (TypeError, ValueError): + raise UserError(_('ID de contacto inválido.')) + + partner = Partner.browse(partner_id) + if not partner.exists(): + raise UserError(_('O contacto com ID %d não foi encontrado.') % partner_id) + return partner + + # ESTA É A FUNÇÃO QUE DEVES SUBSTITUIR + def _run_validation(self, partner, nif): + if partner: + # Atualiza o NIF e chama a lógica do modelo Python (res_partner.py) + partner.sudo().write({'nif_ao': nif}) + partner.action_validate_nif() + + if partner.nif_ao_validated: + return self._success({ + 'nif': partner.nif_ao, + 'nome': partner.name, + 'estado': partner.nif_ao_state or '', + 'tipo_contribuinte': partner.nif_ao_contributor_type or '', + 'ultima_validacao': fields.Datetime.to_string(partner.nif_ao_last_validation), + }) + else: + return self._error(_('Não foi possível validar o NIF %s na AGT.') % nif) + else: + return self._error(_('Para validar o NIF, o contacto deve estar previamente criado.')) + + def _success(self, data): + return {'success': True, 'data': data} + + def _error(self, message): + return {'success': False, 'error': str(message)} \ No newline at end of file diff --git a/i18n_ao_nif_validator/data/nif_validator_data.xml b/i18n_ao_nif_validator/data/nif_validator_data.xml new file mode 100644 index 0000000..8b07ae2 --- /dev/null +++ b/i18n_ao_nif_validator/data/nif_validator_data.xml @@ -0,0 +1,33 @@ + + + + + + + l10n_ao_nif_validator.agt_endpoint_url + https://validador.agt.minfin.gov.ao + + + + + l10n_ao_nif_validator.agt_request_timeout + 3 + + + + + l10n_ao_nif_validator.agt_auto_validate + True + + + + + l10n_ao_nif_validator.agt_enable_logs + True + + + \ No newline at end of file diff --git a/i18n_ao_nif_validator/data/nif_validator_rules.xml b/i18n_ao_nif_validator/data/nif_validator_rules.xml new file mode 100644 index 0000000..7246a03 --- /dev/null +++ b/i18n_ao_nif_validator/data/nif_validator_rules.xml @@ -0,0 +1,38 @@ + + + + + + + Log NIF: acesso por empresa (utilizador) + + + + ['|', + ('partner_id.company_id', '=', False), + ('partner_id.company_id', '=', company_id) + ] + + + + + + + + + + Log NIF: acesso total (gestor) + + + [(1, '=', 1)] + + + + + + + \ No newline at end of file diff --git a/i18n_ao_nif_validator/models/__init__.py b/i18n_ao_nif_validator/models/__init__.py new file mode 100644 index 0000000..af3e031 --- /dev/null +++ b/i18n_ao_nif_validator/models/__init__.py @@ -0,0 +1,3 @@ +from . import nif_log +from . import res_partner +from . import res_config_settings \ No newline at end of file diff --git a/i18n_ao_nif_validator/models/__pycache__/__init__.cpython-313.pyc b/i18n_ao_nif_validator/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..1c64874 Binary files /dev/null and b/i18n_ao_nif_validator/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/i18n_ao_nif_validator/models/__pycache__/nif_log.cpython-313.pyc b/i18n_ao_nif_validator/models/__pycache__/nif_log.cpython-313.pyc new file mode 100644 index 0000000..9eb7ff1 Binary files /dev/null and b/i18n_ao_nif_validator/models/__pycache__/nif_log.cpython-313.pyc differ diff --git a/i18n_ao_nif_validator/models/__pycache__/res_config_settings.cpython-313.pyc b/i18n_ao_nif_validator/models/__pycache__/res_config_settings.cpython-313.pyc new file mode 100644 index 0000000..1232f5a Binary files /dev/null and b/i18n_ao_nif_validator/models/__pycache__/res_config_settings.cpython-313.pyc differ diff --git a/i18n_ao_nif_validator/models/__pycache__/res_partner.cpython-313.pyc b/i18n_ao_nif_validator/models/__pycache__/res_partner.cpython-313.pyc new file mode 100644 index 0000000..439fbb9 Binary files /dev/null and b/i18n_ao_nif_validator/models/__pycache__/res_partner.cpython-313.pyc differ diff --git a/i18n_ao_nif_validator/models/nif_log.py b/i18n_ao_nif_validator/models/nif_log.py new file mode 100644 index 0000000..d366542 --- /dev/null +++ b/i18n_ao_nif_validator/models/nif_log.py @@ -0,0 +1,29 @@ +from odoo import models, fields + + +class NifValidationLog(models.Model): + _name = 'nif.validation.log' + _description = 'Log de Validação de NIF Angola' + _order = 'create_date desc' + _inherit = ['mail.thread'] # Para chatter funcionar como no teu XML + + partner_id = fields.Many2one('res.partner', string="Contacto", readonly=True) + nif = fields.Char(string="NIF", readonly=True) + state = fields.Selection([ + ('success', 'Sucesso'), + ('not_found', 'Não Encontrado'), + ('error', 'Erro'), + ('connection_error', 'Erro de Ligação') + ], string="Estado", readonly=True) + + http_status_code = fields.Integer(string="Código HTTP", readonly=True) + duration_ms = fields.Float(string="Duração (ms)", readonly=True) + error_message = fields.Text(string="Mensagem de Erro", readonly=True) + raw_response = fields.Text(string="Resposta JSON", readonly=True) + is_success = fields.Boolean(string="Sucesso", readonly=True) + + # Campos da resposta AGT + response_nif = fields.Char(string="NIF (AGT)") + response_name = fields.Char(string="Nome (AGT)") + response_state = fields.Char(string="Estado Fiscal") + response_type = fields.Char(string="Tipo Contribuinte") \ No newline at end of file diff --git a/i18n_ao_nif_validator/models/res_config_settings.py b/i18n_ao_nif_validator/models/res_config_settings.py new file mode 100644 index 0000000..e1c5cee --- /dev/null +++ b/i18n_ao_nif_validator/models/res_config_settings.py @@ -0,0 +1,21 @@ +from odoo import models, fields + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + nif_agt_endpoint_url = fields.Char( + string="URL do Endpoint AGT", + config_parameter='l10n_ao_nif_validator.agt_endpoint_url' + ) + nif_agt_request_timeout = fields.Integer( + string="Timeout (segundos)", + config_parameter='l10n_ao_nif_validator.agt_request_timeout' + ) + nif_agt_auto_validate = fields.Boolean( + string="Validação Automática", + config_parameter='l10n_ao_nif_validator.agt_auto_validate' + ) + nif_agt_enable_logs = fields.Boolean( + string="Activar Registo de Logs", + config_parameter='l10n_ao_nif_validator.agt_enable_logs' + ) \ No newline at end of file diff --git a/i18n_ao_nif_validator/models/res_partner.py b/i18n_ao_nif_validator/models/res_partner.py new file mode 100644 index 0000000..2f54d32 --- /dev/null +++ b/i18n_ao_nif_validator/models/res_partner.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- + +import requests +import re +import urllib3 +import logging +from datetime import datetime + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + # 1. CAMPOS + nif_ao = fields.Char(string="NIF Angola", copy=False) + nif_ao_validated = fields.Boolean(string="NIF Validado", default=False, copy=False, readonly=True) + nif_ao_state = fields.Char(string="Estado Fiscal (AGT)", readonly=True) + nif_ao_contributor_type = fields.Char(string="Tipo de Contribuinte", readonly=True) + nif_ao_inadimplente = fields.Char(string="Inadimplente", readonly=True) + nif_ao_regime_iva = fields.Char(string="Regime de IVA", readonly=True) + nif_ao_last_validation = fields.Datetime(string="Última Validação", readonly=True) + nif_ao_validation_count = fields.Integer(string="Consultas", compute='_compute_nif_logs_count') + + # Contagem dos logs para mostrar no botão inteligente + def _compute_nif_logs_count(self): + for record in self: + if self.env.get('nif.validation.log'): + record.nif_ao_validation_count = self.env['nif.validation.log'].search_count([ + ('partner_id', '=', record.id) + ]) + else: + record.nif_ao_validation_count = 0 + + # Abre a janela do histórico + def action_open_nif_logs(self): + self.ensure_one() + return { + 'name': _('Logs de Consulta NIF'), + 'type': 'ir.actions.act_window', + 'res_model': 'nif.validation.log', + 'view_mode': 'list,form', + 'domain': [('partner_id', '=', self.id)], + 'target': 'current', + } + + # NOVO: Função para apagar o histórico de consultas deste cliente + def action_clear_nif_logs(self): + self.ensure_one() + if self.env.get('nif.validation.log'): + logs = self.env['nif.validation.log'].search([('partner_id', '=', self.id)]) + logs.unlink() # Apaga os registos da base de dados + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Histórico Apagado'), + 'message': _('Todo o histórico de consultas deste NIF foi apagado.'), + 'type': 'warning', + } + } + + # 2. LÓGICA DE EXTRAÇÃO (API AGT) + @api.onchange('nif_ao') + def _onchange_nif_ao_fetch_agt(self): + if not self.nif_ao or len(self.nif_ao.strip()) < 9: + return + + nif = self.nif_ao.strip() + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + url = f"https://portaldocontribuinte.minfin.gov.ao/consultar-nif-do-contribuinte?nif={nif}" + headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/122.0.0.0'} + + try: + _logger.info(">>> A consultar AGT para NIF: %s", nif) + response = requests.get(url, headers=headers, timeout=15, verify=False) + + if response.status_code == 200 and response.text: + html = response.text + clean_text = re.sub(r'<[^>]+>', ' ', html) + clean_text = re.sub(r'\s+', ' ', clean_text).strip() + + nome_m = re.search(r'Nome:\s*(.*?)\s*Tipo:', clean_text, re.IGNORECASE) + tipo_m = re.search(r'Tipo:\s*(.*?)\s*Estado:', clean_text, re.IGNORECASE) + est_m = re.search(r'Estado:\s*(.*?)\s*Inadimplente:', clean_text, re.IGNORECASE) + inad_m = re.search(r'Inadimplente:\s*(.*?)\s*Regime de IVA:', clean_text, re.IGNORECASE) + iva_m = re.search(r'Regime de IVA:\s*(.*?)\s*Residente Fiscal', clean_text, re.IGNORECASE) + + if nome_m: + self.name = nome_m.group(1).strip() + if tipo_m: + self.nif_ao_contributor_type = tipo_m.group(1).strip() + if est_m: + self.nif_ao_state = est_m.group(1).strip() + if inad_m: + self.nif_ao_inadimplente = inad_m.group(1).strip() + if iva_m: + self.nif_ao_regime_iva = iva_m.group(1).strip() + + self.nif_ao_validated = True + self.nif_ao_last_validation = fields.Datetime.now() + + except Exception as e: + _logger.error("Erro na busca: %s", str(e)) + + # 3. AÇÃO DO BOTÃO "CONSULTAR" (Agora regista os Logs a sério!) + def action_validate_nif(self): + self.ensure_one() + if not self.nif_ao: + raise UserError(_("Por favor, preencha o campo NIF Angola primeiro.")) + + # 1. Faz a pesquisa no portal da AGT + self._onchange_nif_ao_fetch_agt() + + # 2. Se a pesquisa correu bem, cria o registo no Histórico (Log) + if self.nif_ao_validated: + if self.env.get('nif.validation.log'): + self.env['nif.validation.log'].create({ + 'partner_id': self.id, + 'nif': self.nif_ao, + 'state': 'Sucesso', + 'raw_response': f"Nome: {self.name} | Estado: {self.nif_ao_state} | IVA: {self.nif_ao_regime_iva}" + }) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Sucesso'), + 'message': _('Dados da AGT consultados com sucesso e registados no histórico.'), + 'type': 'success', + } + } + else: + raise UserError(_("Não foi possível aceder à AGT. Verifique o NIF.")) \ No newline at end of file diff --git a/i18n_ao_nif_validator/security/ir.model.access.csv b/i18n_ao_nif_validator/security/ir.model.access.csv new file mode 100644 index 0000000..5dc33a1 --- /dev/null +++ b/i18n_ao_nif_validator/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_nif_log_user,nif.validation.log.user,model_nif_validation_log,group_nif_ao_user,1,0,0,0 +access_nif_log_manager,nif.validation.log.manager,model_nif_validation_log,group_nif_ao_manager,1,1,1,1 \ No newline at end of file diff --git a/i18n_ao_nif_validator/security/nif_validator_security.xml b/i18n_ao_nif_validator/security/nif_validator_security.xml new file mode 100644 index 0000000..3cffd4d --- /dev/null +++ b/i18n_ao_nif_validator/security/nif_validator_security.xml @@ -0,0 +1,22 @@ + + + + Validação NIF Angola (AGT) + Controle de níveis de acesso para consulta de NIFs na AGT. + 90 + + + + Utilizador + + + + + Gestor + + + + + + + \ No newline at end of file diff --git a/i18n_ao_nif_validator/static/src/css/nif_validator.css b/i18n_ao_nif_validator/static/src/css/nif_validator.css new file mode 100644 index 0000000..e69de29 diff --git a/i18n_ao_nif_validator/static/src/js/nif_validator.js b/i18n_ao_nif_validator/static/src/js/nif_validator.js new file mode 100644 index 0000000..e69de29 diff --git a/i18n_ao_nif_validator/tests/tests_nif_validator.py b/i18n_ao_nif_validator/tests/tests_nif_validator.py new file mode 100644 index 0000000..25a5805 --- /dev/null +++ b/i18n_ao_nif_validator/tests/tests_nif_validator.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +""" +Testes unitarios — integracao com a API AGT +Foco: logica de chamada HTTP, tratamento de erros e registo de logs + +Todos os testes que tocam a rede usam unittest.mock.patch para interceptar +requests.get e simular respostas sem precisar de conectividade real. +""" + +import json +from unittest.mock import patch, MagicMock + +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError, ValidationError + + +# Caminho completo do metodo a fazer mock dentro do modulo res.partner +_REQUESTS_GET = 'odoo.addons.l10n_ao_nif_validator.models.res_partner.requests.get' + + +def _mock_response(status_code=200, json_data=None, raise_exc=None): + """ + Fabrica um objeto mock que imita requests.Response. + + :param status_code: codigo HTTP a retornar + :param json_data: dict retornado por response.json() + :param raise_exc: excecao a lancar ao chamar requests.get (timeout, etc.) + """ + mock = MagicMock() + mock.status_code = status_code + if json_data is not None: + mock.json.return_value = json_data + if raise_exc: + mock.side_effect = raise_exc + return mock + + +class TestNifFormatValidation(TransactionCase): + """ + Grupo 1 — Validacao de formato local (sem chamada HTTP) + Testa o metodo estatico _is_valid_nif_format diretamente. + """ + + def setUp(self): + super().setUp() + self.Partner = self.env['res.partner'] + + # --- Formatos validos ------------------------------------------------------- + + def test_valid_nif_10_digits(self): + """NIF com exactamente 10 digitos deve ser valido.""" + self.assertTrue(self.Partner._is_valid_nif_format('5001234567')) + + def test_valid_nif_starts_with_zero(self): + """NIF iniciado por zero deve ser aceite (e uma string, nao inteiro).""" + self.assertTrue(self.Partner._is_valid_nif_format('0001234567')) + + # --- Formatos invalidos ----------------------------------------------------- + + def test_invalid_nif_too_short(self): + """NIF com menos de 10 digitos deve ser invalido.""" + self.assertFalse(self.Partner._is_valid_nif_format('500123')) + + def test_invalid_nif_too_long(self): + """NIF com mais de 10 digitos deve ser invalido.""" + self.assertFalse(self.Partner._is_valid_nif_format('50012345678')) + + def test_invalid_nif_with_letters(self): + """NIF com letras deve ser invalido.""" + self.assertFalse(self.Partner._is_valid_nif_format('500123456A')) + + def test_invalid_nif_with_spaces(self): + """NIF com espacos deve ser invalido.""" + self.assertFalse(self.Partner._is_valid_nif_format('500 123456')) + + def test_invalid_nif_with_dots(self): + """NIF com pontos (formatado) deve ser invalido.""" + self.assertFalse(self.Partner._is_valid_nif_format('500.123.456')) + + def test_invalid_nif_empty(self): + """NIF vazio deve ser invalido.""" + self.assertFalse(self.Partner._is_valid_nif_format('')) + + def test_invalid_nif_none(self): + """NIF None deve ser invalido sem lancar excecao.""" + self.assertFalse(self.Partner._is_valid_nif_format(None)) + + +class TestAgtApiCall(TransactionCase): + """ + Grupo 2 — Chamada HTTP ao endpoint AGT + Testa _fetch_and_fill_agt_data com respostas simuladas. + """ + + def setUp(self): + super().setUp() + # Configura o URL do endpoint para que os testes nao falhem + # por falta de configuracao + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_endpoint_url', + 'https://mock.agt.test' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_request_timeout', '3' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_enable_logs', 'True' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_auto_validate', 'True' + ) + + self.partner = self.env['res.partner'].create({ + 'name' : 'Parceiro Teste', + 'nif_ao' : '5001234567', + }) + + # --- Resposta 200 (sucesso) ------------------------------------------------- + + @patch(_REQUESTS_GET) + def test_api_success_fills_partner_fields(self, mock_get): + """Resposta 200 deve preencher nome, estado e tipo no parceiro.""" + mock_get.return_value = _mock_response(200, { + 'nif' : '5001234567', + 'nome' : 'Hilari Tech LDA', + 'estado' : 'Ativo', + 'tipo_contribuinte': 'Empresa', + }) + + self.partner._fetch_and_fill_agt_data() + + self.assertEqual(self.partner.name, 'Hilari Tech LDA') + self.assertEqual(self.partner.nif_ao_state, 'Ativo') + self.assertEqual(self.partner.nif_ao_contributor_type, 'Empresa') + self.assertTrue(self.partner.nif_ao_validated) + self.assertIsNotNone(self.partner.nif_ao_last_validation) + + @patch(_REQUESTS_GET) + def test_api_success_partial_data(self, mock_get): + """Resposta 200 com campos opcionais em falta nao deve lancar erro.""" + mock_get.return_value = _mock_response(200, { + 'nif' : '5001234567', + 'nome': 'Empresa Parcial LDA', + # 'estado' e 'tipo_contribuinte' ausentes intencionalmente + }) + + self.partner._fetch_and_fill_agt_data() + + self.assertEqual(self.partner.name, 'Empresa Parcial LDA') + self.assertTrue(self.partner.nif_ao_validated) + + # --- Resposta 404 (nao encontrado) ------------------------------------------ + + @patch(_REQUESTS_GET) + def test_api_404_partner_not_validated(self, mock_get): + """Resposta 404 nao deve marcar o parceiro como validado.""" + mock_get.return_value = _mock_response(404) + + self.partner._fetch_and_fill_agt_data() + + self.assertFalse(self.partner.nif_ao_validated) + + # --- Outros codigos de erro HTTP ------------------------------------------- + + @patch(_REQUESTS_GET) + def test_api_500_partner_not_validated(self, mock_get): + """Resposta 500 nao deve marcar o parceiro como validado.""" + mock_get.return_value = _mock_response(500) + + self.partner._fetch_and_fill_agt_data() + + self.assertFalse(self.partner.nif_ao_validated) + + # --- Timeout ---------------------------------------------------------------- + + @patch(_REQUESTS_GET) + def test_api_timeout_partner_not_validated(self, mock_get): + """Timeout na requisicao nao deve marcar o parceiro como validado.""" + import requests as req_lib + mock_get.side_effect = req_lib.exceptions.Timeout() + + self.partner._fetch_and_fill_agt_data() + + self.assertFalse(self.partner.nif_ao_validated) + + # --- Erro de conexao -------------------------------------------------------- + + @patch(_REQUESTS_GET) + def test_api_connection_error_partner_not_validated(self, mock_get): + """Erro de conexao nao deve marcar o parceiro como validado.""" + import requests as req_lib + mock_get.side_effect = req_lib.exceptions.ConnectionError("recusada") + + self.partner._fetch_and_fill_agt_data() + + self.assertFalse(self.partner.nif_ao_validated) + + # --- Resposta JSON invalida ------------------------------------------------- + + @patch(_REQUESTS_GET) + def test_api_invalid_json_partner_not_validated(self, mock_get): + """Resposta 200 com JSON invalido nao deve marcar o parceiro como validado.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.side_effect = ValueError("JSON invalido") + mock_resp.text = "erro" + mock_get.return_value = mock_resp + + self.partner._fetch_and_fill_agt_data() + + self.assertFalse(self.partner.nif_ao_validated) + + # --- Endpoint nao configurado ----------------------------------------------- + + def test_missing_endpoint_raises_user_error(self): + """Sem URL configurado deve lancar UserError.""" + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_endpoint_url', '' + ) + + with self.assertRaises(UserError): + self.partner._fetch_and_fill_agt_data() + + +class TestNifValidationLogs(TransactionCase): + """ + Grupo 3 — Registo de logs (nif.validation.log) + Verifica que os logs sao criados correctamente em cada cenario. + """ + + def setUp(self): + super().setUp() + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_endpoint_url', 'https://mock.agt.test' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_enable_logs', 'True' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_request_timeout', '3' + ) + + self.partner = self.env['res.partner'].create({ + 'name' : 'Parceiro Log Teste', + 'nif_ao' : '5009876543', + }) + self.NifLog = self.env['nif.validation.log'] + + @patch(_REQUESTS_GET) + def test_log_created_on_success(self, mock_get): + """Um log com state='success' deve ser criado apos validacao bem-sucedida.""" + mock_get.return_value = _mock_response(200, { + 'nif' : '5009876543', + 'nome': 'Teste Log LDA', + 'estado': 'Ativo', + 'tipo_contribuinte': 'Empresa', + }) + + count_before = self.NifLog.search_count([ + ('partner_id', '=', self.partner.id), + ('state', '=', 'success'), + ]) + + self.partner._fetch_and_fill_agt_data() + + count_after = self.NifLog.search_count([ + ('partner_id', '=', self.partner.id), + ('state', '=', 'success'), + ]) + + self.assertEqual(count_after, count_before + 1) + + @patch(_REQUESTS_GET) + def test_log_created_on_404(self, mock_get): + """Um log com state='not_found' deve ser criado quando AGT retorna 404.""" + mock_get.return_value = _mock_response(404) + + self.partner._fetch_and_fill_agt_data() + + log = self.NifLog.search([ + ('partner_id', '=', self.partner.id), + ('state', '=', 'not_found'), + ], limit=1) + + self.assertTrue(bool(log), "Log 'not_found' nao foi criado") + + @patch(_REQUESTS_GET) + def test_log_created_on_timeout(self, mock_get): + """Um log com state='timeout' deve ser criado em caso de timeout.""" + import requests as req_lib + mock_get.side_effect = req_lib.exceptions.Timeout() + + self.partner._fetch_and_fill_agt_data() + + log = self.NifLog.search([ + ('partner_id', '=', self.partner.id), + ('state', '=', 'timeout'), + ], limit=1) + + self.assertTrue(bool(log), "Log 'timeout' nao foi criado") + + @patch(_REQUESTS_GET) + def test_log_created_on_connection_error(self, mock_get): + """Um log com state='connection_error' deve ser criado em erros de rede.""" + import requests as req_lib + mock_get.side_effect = req_lib.exceptions.ConnectionError() + + self.partner._fetch_and_fill_agt_data() + + log = self.NifLog.search([ + ('partner_id', '=', self.partner.id), + ('state', '=', 'connection_error'), + ], limit=1) + + self.assertTrue(bool(log), "Log 'connection_error' nao foi criado") + + @patch(_REQUESTS_GET) + def test_no_log_when_logs_disabled(self, mock_get): + """Nenhum log deve ser criado quando agt_enable_logs=False.""" + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_enable_logs', 'False' + ) + mock_get.return_value = _mock_response(200, { + 'nif' : '5009876543', + 'nome': 'Sem Log LDA', + 'estado': 'Ativo', + 'tipo_contribuinte': 'Empresa', + }) + + count_before = self.NifLog.search_count([ + ('partner_id', '=', self.partner.id), + ]) + + self.partner._fetch_and_fill_agt_data() + + count_after = self.NifLog.search_count([ + ('partner_id', '=', self.partner.id), + ]) + + self.assertEqual(count_after, count_before, "Log criado com logs desactivados") + + @patch(_REQUESTS_GET) + def test_log_contains_nif_and_partner(self, mock_get): + """O log de sucesso deve conter o NIF consultado e o partner correcto.""" + mock_get.return_value = _mock_response(200, { + 'nif' : '5009876543', + 'nome': 'Dados Log LDA', + 'estado': 'Ativo', + 'tipo_contribuinte': 'Empresa', + }) + + self.partner._fetch_and_fill_agt_data() + + log = self.NifLog.search([ + ('partner_id', '=', self.partner.id), + ('nif', '=', '5009876543'), + ('state', '=', 'success'), + ], limit=1) + + self.assertTrue(bool(log)) + self.assertEqual(log.nif, '5009876543') + self.assertEqual(log.partner_id, self.partner) + self.assertEqual(log.response_name, 'Dados Log LDA') + + @patch(_REQUESTS_GET) + def test_log_duration_ms_recorded(self, mock_get): + """O log deve conter a duracao da chamada em milissegundos (>= 0).""" + mock_get.return_value = _mock_response(200, { + 'nif' : '5009876543', + 'nome': 'Duracao LDA', + 'estado': 'Ativo', + 'tipo_contribuinte': 'Empresa', + }) + + self.partner._fetch_and_fill_agt_data() + + log = self.NifLog.search([ + ('partner_id', '=', self.partner.id), + ('state', '=', 'success'), + ], order='create_date desc', limit=1) + + self.assertGreaterEqual(log.duration_ms, 0) \ No newline at end of file diff --git a/i18n_ao_nif_validator/tests/tests_res_partner.py b/i18n_ao_nif_validator/tests/tests_res_partner.py new file mode 100644 index 0000000..65e4700 --- /dev/null +++ b/i18n_ao_nif_validator/tests/tests_res_partner.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +""" +Testes de integracao — modelo res.partner +Foco: campos NIF, accoes do formulario, validacao manual e reset de campos. +""" + +from unittest.mock import patch, MagicMock + +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError, ValidationError + + +_REQUESTS_GET = 'odoo.addons.l10n_ao_nif_validator.models.res_partner.requests.get' + + +def _agt_ok(nif='5001234567', nome='Empresa Teste LDA', + estado='Ativo', tipo='Empresa'): + """Atalho para uma resposta AGT bem-sucedida.""" + mock = MagicMock() + mock.status_code = 200 + mock.json.return_value = { + 'nif' : nif, + 'nome' : nome, + 'estado' : estado, + 'tipo_contribuinte': tipo, + } + return mock + + +class TestResPartnerFields(TransactionCase): + """ + Grupo 1 — Campos NIF no modelo res.partner + Verifica que os campos existem e tem os defaults correctos. + """ + + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'Teste Campos'}) + + def test_nif_ao_field_exists(self): + """Campo nif_ao deve existir no modelo.""" + self.assertIn('nif_ao', self.env['res.partner']._fields) + + def test_nif_ao_validated_default_false(self): + """nif_ao_validated deve ser False por defeito.""" + self.assertFalse(self.partner.nif_ao_validated) + + def test_nif_ao_state_default_empty(self): + """nif_ao_state deve estar vazio por defeito.""" + self.assertFalse(self.partner.nif_ao_state) + + def test_nif_ao_contributor_type_default_empty(self): + """nif_ao_contributor_type deve estar vazio por defeito.""" + self.assertFalse(self.partner.nif_ao_contributor_type) + + def test_nif_ao_last_validation_default_empty(self): + """nif_ao_last_validation deve estar vazio por defeito.""" + self.assertFalse(self.partner.nif_ao_last_validation) + + def test_nif_ao_validation_count_default_zero(self): + """nif_ao_validation_count deve ser 0 para parceiro sem historico.""" + self.assertEqual(self.partner.nif_ao_validation_count, 0) + + +class TestResPartnerValidation(TransactionCase): + """ + Grupo 2 — Accao action_validate_nif (botao manual) + Verifica comportamento correcto com e sem NIF preenchido. + """ + + def setUp(self): + super().setUp() + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_endpoint_url', 'https://mock.agt.test' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_request_timeout', '3' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_enable_logs', 'True' + ) + + def test_action_validate_nif_raises_if_no_nif(self): + """action_validate_nif deve lancar UserError se nif_ao estiver vazio.""" + partner = self.env['res.partner'].create({'name': 'Sem NIF'}) + with self.assertRaises(UserError): + partner.action_validate_nif() + + def test_action_validate_nif_raises_if_format_invalid(self): + """action_validate_nif deve lancar ValidationError se formato invalido.""" + partner = self.env['res.partner'].create({ + 'name' : 'NIF Invalido', + 'nif_ao': '123', + }) + with self.assertRaises(ValidationError): + partner.action_validate_nif() + + @patch(_REQUESTS_GET) + def test_action_validate_nif_returns_notification(self, mock_get): + """action_validate_nif deve retornar uma accao de notificacao.""" + mock_get.return_value = _agt_ok() + partner = self.env['res.partner'].create({ + 'name' : 'Com NIF', + 'nif_ao': '5001234567', + }) + result = partner.action_validate_nif() + self.assertIsInstance(result, dict) + self.assertEqual(result.get('type'), 'ir.actions.client') + self.assertEqual(result.get('tag'), 'display_notification') + + @patch(_REQUESTS_GET) + def test_action_validate_nif_success_sets_validated(self, mock_get): + """Validacao bem-sucedida deve definir nif_ao_validated=True.""" + mock_get.return_value = _agt_ok(nome='Hilari Tech LDA') + partner = self.env['res.partner'].create({ + 'name' : 'Antes da Validacao', + 'nif_ao': '5001234567', + }) + partner.action_validate_nif() + self.assertTrue(partner.nif_ao_validated) + + @patch(_REQUESTS_GET) + def test_partner_name_updated_from_agt(self, mock_get): + """O nome do parceiro deve ser actualizado com o nome retornado pela AGT.""" + mock_get.return_value = _agt_ok(nome='Novo Nome AGT LDA') + partner = self.env['res.partner'].create({ + 'name' : 'Nome Antigo', + 'nif_ao': '5001234567', + }) + partner.action_validate_nif() + self.assertEqual(partner.name, 'Novo Nome AGT LDA') + + @patch(_REQUESTS_GET) + def test_partner_state_filled_from_agt(self, mock_get): + """nif_ao_state deve ser preenchido com o estado retornado pela AGT.""" + mock_get.return_value = _agt_ok(estado='Ativo') + partner = self.env['res.partner'].create({ + 'name' : 'Estado Teste', + 'nif_ao': '5001234567', + }) + partner.action_validate_nif() + self.assertEqual(partner.nif_ao_state, 'Ativo') + + @patch(_REQUESTS_GET) + def test_partner_type_filled_from_agt(self, mock_get): + """nif_ao_contributor_type deve ser preenchido com o tipo retornado pela AGT.""" + mock_get.return_value = _agt_ok(tipo='Empresa') + partner = self.env['res.partner'].create({ + 'name' : 'Tipo Teste', + 'nif_ao': '5001234567', + }) + partner.action_validate_nif() + self.assertEqual(partner.nif_ao_contributor_type, 'Empresa') + + +class TestResPartnerResetFields(TransactionCase): + """ + Grupo 3 — Reset de campos ao alterar o NIF + Verifica que _reset_nif_fields limpa os dados anteriores. + """ + + def setUp(self): + super().setUp() + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_endpoint_url', 'https://mock.agt.test' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_request_timeout', '3' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_enable_logs', 'False' + ) + + @patch(_REQUESTS_GET) + def test_reset_clears_validated_flag(self, mock_get): + """_reset_nif_fields deve limpar nif_ao_validated.""" + mock_get.return_value = _agt_ok() + partner = self.env['res.partner'].create({ + 'name' : 'Reset Teste', + 'nif_ao': '5001234567', + }) + partner._fetch_and_fill_agt_data() + self.assertTrue(partner.nif_ao_validated) + + # Simula alteracao do NIF — chama o reset + partner._reset_nif_fields() + self.assertFalse(partner.nif_ao_validated) + + @patch(_REQUESTS_GET) + def test_reset_clears_state_and_type(self, mock_get): + """_reset_nif_fields deve limpar nif_ao_state e nif_ao_contributor_type.""" + mock_get.return_value = _agt_ok(estado='Ativo', tipo='Empresa') + partner = self.env['res.partner'].create({ + 'name' : 'Reset State Tipo', + 'nif_ao': '5001234567', + }) + partner._fetch_and_fill_agt_data() + self.assertTrue(partner.nif_ao_state) + + partner._reset_nif_fields() + self.assertFalse(partner.nif_ao_state) + self.assertFalse(partner.nif_ao_contributor_type) + + +class TestResPartnerLogLink(TransactionCase): + """ + Grupo 4 — Ligacao entre res.partner e nif.validation.log + Verifica o One2many e o smart button de contagem. + """ + + def setUp(self): + super().setUp() + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_endpoint_url', 'https://mock.agt.test' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_request_timeout', '3' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_enable_logs', 'True' + ) + + @patch(_REQUESTS_GET) + def test_validation_count_increments(self, mock_get): + """Cada validacao deve incrementar nif_ao_validation_count.""" + mock_get.return_value = _agt_ok() + partner = self.env['res.partner'].create({ + 'name' : 'Contador', + 'nif_ao': '5001234567', + }) + + self.assertEqual(partner.nif_ao_validation_count, 0) + partner._fetch_and_fill_agt_data() + self.assertEqual(partner.nif_ao_validation_count, 1) + partner._fetch_and_fill_agt_data() + self.assertEqual(partner.nif_ao_validation_count, 2) + + @patch(_REQUESTS_GET) + def test_action_open_nif_logs_returns_window(self, mock_get): + """action_open_nif_logs deve retornar um act_window filtrado pelo partner.""" + mock_get.return_value = _agt_ok() + partner = self.env['res.partner'].create({ + 'name' : 'Abrir Logs', + 'nif_ao': '5001234567', + }) + partner._fetch_and_fill_agt_data() + + result = partner.action_open_nif_logs() + + self.assertEqual(result['type'], 'ir.actions.act_window') + self.assertEqual(result['res_model'], 'nif.validation.log') + self.assertIn(('partner_id', '=', partner.id), result['domain']) \ No newline at end of file diff --git a/i18n_ao_nif_validator/views/menu.xml b/i18n_ao_nif_validator/views/menu.xml new file mode 100644 index 0000000..6674515 --- /dev/null +++ b/i18n_ao_nif_validator/views/menu.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/i18n_ao_nif_validator/views/nif_log_view.xml b/i18n_ao_nif_validator/views/nif_log_view.xml new file mode 100644 index 0000000..d44cb55 --- /dev/null +++ b/i18n_ao_nif_validator/views/nif_log_view.xml @@ -0,0 +1,88 @@ + + + + + nif.validation.log.list + nif.validation.log + + + + + + + + + + + + + nif.validation.log.kanban + nif.validation.log + + + + + + + + + + NIF: + + Nome: + Data: + + + + + + + + + + + + nif.validation.log.form + nif.validation.log + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Logs de Validação NIF + nif.validation.log + list,kanban,form + + + \ No newline at end of file diff --git a/i18n_ao_nif_validator/views/res_config_settings_view.xml b/i18n_ao_nif_validator/views/res_config_settings_view.xml new file mode 100644 index 0000000..bd84e5c --- /dev/null +++ b/i18n_ao_nif_validator/views/res_config_settings_view.xml @@ -0,0 +1,54 @@ + + + + + res.config.settings.view.nif.ao + res.config.settings + + + + + + + + + + Parâmetros de integração com o endpoint oficial da Administração Geral Tributária (AGT) de Angola. + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/i18n_ao_nif_validator/views/res_partner_view.xml b/i18n_ao_nif_validator/views/res_partner_view.xml new file mode 100644 index 0000000..9540bb1 --- /dev/null +++ b/i18n_ao_nif_validator/views/res_partner_view.xml @@ -0,0 +1,66 @@ + + + + + res.partner.form.nif.ao + res.partner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + res.partner.list.nif.ao + res.partner + + + + + + + + + + \ No newline at end of file