primeiro commit

This commit is contained in:
Gelson do Souto 2026-03-11 19:50:52 +00:00
parent bfcd5a0db6
commit e1b0773fcb
29 changed files with 1267 additions and 0 deletions

View File

View File

View File

@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@ -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,
}

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import main

View File

@ -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)}

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!--
noupdate="1" garante que estes valores so sao inseridos na primeira
instalacao. Atualizacoes do modulo NAO sobrescrevem configuracoes
que o administrador tenha alterado manualmente.
-->
<!-- URL base do endpoint AGT -->
<record id="param_agt_endpoint_url" model="ir.config_parameter">
<field name="key">l10n_ao_nif_validator.agt_endpoint_url</field>
<field name="value">https://validador.agt.minfin.gov.ao</field>
</record>
<!-- Timeout da requisicao HTTP (segundos) -->
<record id="param_agt_request_timeout" model="ir.config_parameter">
<field name="key">l10n_ao_nif_validator.agt_request_timeout</field>
<field name="value">3</field>
</record>
<!-- Validacao automatica activa por defeito -->
<record id="param_agt_auto_validate" model="ir.config_parameter">
<field name="key">l10n_ao_nif_validator.agt_auto_validate</field>
<field name="value">True</field>
</record>
<!-- Registo de logs activo por defeito -->
<record id="param_agt_enable_logs" model="ir.config_parameter">
<field name="key">l10n_ao_nif_validator.agt_enable_logs</field>
<field name="value">True</field>
</record>
</odoo>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
ir.rule carregadas DEPOIS do CSV de ACLs e dos dados padrão.
Neste ponto o modelo nif.validation.log já existe na BD
e os grupos já foram criados pelo security XML.
-->
<!-- Utilizador: vê apenas logs de contactos da sua empresa -->
<record id="rule_nif_log_user" model="ir.rule">
<field name="name">Log NIF: acesso por empresa (utilizador)</field>
<field name="model_id" ref="model_nif_validation_log"/>
<field name="groups" eval="[(4, ref('l10n_ao_nif_validator.group_nif_ao_user'))]"/>
<field name="domain_force">
['|',
('partner_id.company_id', '=', False),
('partner_id.company_id', '=', company_id)
]
</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Gestor: vê todos os logs sem restrição -->
<record id="rule_nif_log_manager" model="ir.rule">
<field name="name">Log NIF: acesso total (gestor)</field>
<field name="model_id" ref="model_nif_validation_log"/>
<field name="groups" eval="[(4, ref('l10n_ao_nif_validator.group_nif_ao_manager'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
</odoo>

View File

@ -0,0 +1,3 @@
from . import nif_log
from . import res_partner
from . import res_config_settings

View File

@ -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")

View File

@ -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'
)

View File

@ -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."))

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_nif_log_user nif.validation.log.user model_nif_validation_log group_nif_ao_user 1 0 0 0
3 access_nif_log_manager nif.validation.log.manager model_nif_validation_log group_nif_ao_manager 1 1 1 1

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1"> <record id="module_category_nif_ao" model="ir.module.category">
<field name="name">Validação NIF Angola (AGT)</field>
<field name="description">Controle de níveis de acesso para consulta de NIFs na AGT.</field>
<field name="sequence">90</field>
</record>
<record id="group_nif_ao_user" model="res.groups">
<field name="name">Utilizador</field> <field name="category_id" ref="module_category_nif_ao"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="group_nif_ao_manager" model="res.groups">
<field name="name">Gestor</field>
<field name="category_id" ref="module_category_nif_ao"/>
<field name="implied_ids" eval="[(4, ref('group_nif_ao_user'))]"/>
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
</data>
</odoo>

View File

@ -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 = "<html>erro</html>"
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)

View File

@ -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'])

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_nif_ao_root"
name="Validação NIF (AGT)"
parent="contacts.menu_contacts"
sequence="99"
groups="base.group_user"/>
<menuitem id="menu_nif_ao_logs"
name="Histórico de Consultas"
parent="menu_nif_ao_root"
action="action_nif_validation_log"
sequence="10"
groups="base.group_user"/>
<menuitem id="menu_nif_ao_settings"
name="Configurações AGT"
parent="menu_nif_ao_root"
action="base_setup.action_general_configuration"
sequence="20"
groups="base.group_system"/>
</odoo>

View File

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_nif_log_tree" model="ir.ui.view">
<field name="name">nif.validation.log.list</field>
<field name="model">nif.validation.log</field>
<field name="arch" type="xml">
<list string="Logs de Validação NIF" default_order="create_date desc" create="false" edit="false">
<field name="create_date" string="Data"/>
<field name="nif" string="NIF"/>
<field name="partner_id" string="Contacto"/>
<field name="state" string="Estado" widget="badge"
decoration-success="state == 'success'"
decoration-danger="state == 'error'"
decoration-warning="state == 'not_found'"/>
<field name="duration_ms" string="Duração (ms)"/>
</list>
</field>
</record>
<record id="view_nif_log_kanban" model="ir.ui.view">
<field name="name">nif.validation.log.kanban</field>
<field name="model">nif.validation.log</field>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile" create="false" edit="false">
<field name="nif"/>
<field name="state"/>
<field name="response_name"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click">
<div class="o_kanban_record_title">
<strong>NIF: <field name="nif"/></strong>
</div>
<div>Nome: <field name="response_name"/></div>
<div>Data: <field name="create_date"/></div>
<div class="o_kanban_record_bottom">
<field name="state" widget="badge" decoration-success="state == 'success'"/>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_nif_log_form" model="ir.ui.view">
<field name="name">nif.validation.log.form</field>
<field name="model">nif.validation.log</field>
<field name="arch" type="xml">
<form string="Detalhes do Log" create="false" edit="false">
<sheet>
<group>
<group string="Dados da Consulta">
<field name="create_date" readonly="1"/>
<field name="nif" readonly="1"/>
<field name="partner_id" readonly="1"/>
</group>
<group string="Status">
<field name="state" readonly="1" widget="badge"/>
<field name="is_success" readonly="1" widget="boolean_toggle"/>
<field name="http_status_code" readonly="1"/>
</group>
</group>
<group string="Resposta AGT" invisible="not response_name">
<field name="response_nif" readonly="1"/>
<field name="response_name" readonly="1"/>
<field name="response_state" readonly="1"/>
</group>
<group string="Erro/Raw" invisible="not error_message">
<field name="error_message" readonly="1"/>
<field name="raw_response" readonly="1"/>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<record id="action_nif_validation_log" model="ir.actions.act_window">
<field name="name">Logs de Validação NIF</field>
<field name="res_model">nif.validation.log</field>
<field name="view_mode">list,kanban,form</field>
</record>
</odoo>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_res_config_settings_nif_ao" model="ir.ui.view">
<field name="name">res.config.settings.view.nif.ao</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app string="NIF Angola (AGT)"
name="l10n_ao_nif_validator"
data-string="NIF Angola (AGT)"
data-key="l10n_ao_nif_validator">
<block title="Configurações do Validador NIF AGT" id="nif_ao_validation_block">
<div class="text-muted">
Parâmetros de integração com o endpoint oficial da Administração Geral Tributária (AGT) de Angola.
</div>
<div class="row mt16 o_settings_container">
<setting id="nif_agt_endpoint_url"
string="URL do Endpoint AGT"
help="URL base do serviço de validação de NIF. Ex: https://validador.agt.minfin.gov.ao">
<field name="nif_agt_endpoint_url"
placeholder="https://validador.agt.minfin.gov.ao"/>
</setting>
<setting id="nif_agt_request_timeout"
string="Timeout (segundos)"
help="Tempo máximo de espera pela resposta da AGT (Recomendado: 3s).">
<field name="nif_agt_request_timeout"/>
</setting>
<setting id="nif_agt_auto_validate"
string="Validação Automática"
help="O NIF é consultado automaticamente ao salvar um contacto.">
<field name="nif_agt_auto_validate" />
</setting>
<setting id="nif_agt_enable_logs"
string="Activar Registo de Logs"
help="Guarda todas as consultas à API no histórico.">
<field name="nif_agt_enable_logs" />
</setting>
</div>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_partner_form_nif_ao" model="ir.ui.view">
<field name="name">res.partner.form.nif.ao</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet/group" position="before">
<group name="nif_ao_group" string="Identificação Fiscal Angola" col="4">
<field name="nif_ao"
placeholder="Ex: 5001234567"
readonly="nif_ao_validated"/>
<button name="action_validate_nif"
string="Validar NIF"
type="object"
icon="fa-search"
class="btn-primary"
invisible="not nif_ao"/>
<field name="nif_ao_state" readonly="1" invisible="not nif_ao_state"/>
<field name="nif_ao_inadimplente" readonly="1" invisible="not nif_ao_inadimplente"/>
<field name="nif_ao_regime_iva" readonly="1" invisible="not nif_ao_regime_iva"/>
<field name="nif_ao_contributor_type" readonly="1" invisible="not nif_ao_contributor_type"/>
<field name="nif_ao_validated"
widget="boolean_toggle"
readonly="1"
string="NIF Validado"/>
<field name="nif_ao_last_validation"
readonly="1"
invisible="not nif_ao_last_validation"/>
</group>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_open_nif_logs"
type="object"
class="oe_stat_button"
icon="fa-history"
invisible="nif_ao_validation_count == 0">
<field name="nif_ao_validation_count"
widget="statinfo"
string="Validações NIF"/>
</button>
</xpath>
</field>
</record>
<record id="view_partner_tree_nif_ao" model="ir.ui.view">
<field name="name">res.partner.list.nif.ao</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_tree"/>
<field name="arch" type="xml">
<xpath expr="//list//field[@name='email']" position="before">
<field name="nif_ao" string="NIF" optional="show"/>
<field name="nif_ao_validated" string="✓ NIF" widget="boolean" optional="show"/>
</xpath>
</field>
</record>
</odoo>