[ADD] account_peppol: send and receive invoice via peppol
This commits adds `account_peppol` module that allows: - Registering as an account edi user and send an application to the peppol proxy, where the participant can be approved or rejected after reviewing the documents attached. - Sending invoices by selecting 'Send via Peppol' option in the send & print wizard. - Receiving peppol documents. task-3159912 closes odoo/odoo#119120 Signed-off-by: Laurent Smet <las@odoo.com>
This commit is contained in:
@@ -427,9 +427,14 @@ class AccountMoveSend(models.Model):
|
||||
self.ensure_one()
|
||||
return {}
|
||||
|
||||
def _call_web_service(self, prepared_data_list):
|
||||
# TO OVERRIDE
|
||||
return
|
||||
|
||||
def _generate_documents(self, moves, from_cron):
|
||||
""" Main entry point to generate the invoice pdf and the related attachments.
|
||||
"""
|
||||
prepared_data_list = []
|
||||
success, errors = self.env['account.move'], []
|
||||
for move in moves:
|
||||
try:
|
||||
@@ -438,13 +443,23 @@ class AccountMoveSend(models.Model):
|
||||
if prepared_data.get('error'):
|
||||
raise UserError(prepared_data['error'])
|
||||
else:
|
||||
self._render_document(move, prepared_data)
|
||||
self._postprocess_document(move, prepared_data)
|
||||
self._link_document(move, prepared_data)
|
||||
success |= move
|
||||
prepared_data_list.append((move, prepared_data))
|
||||
except UserError as e:
|
||||
errors.append((move, str(e)))
|
||||
|
||||
try:
|
||||
self._call_web_service(prepared_data_list)
|
||||
except UserError as e:
|
||||
for move, _prepared_data in prepared_data_list:
|
||||
errors.append((move, str(e)))
|
||||
return success, errors
|
||||
|
||||
for move, prepared_data in prepared_data_list:
|
||||
self._render_document(move, prepared_data)
|
||||
self._postprocess_document(move, prepared_data)
|
||||
self._link_document(move, prepared_data)
|
||||
success |= move
|
||||
|
||||
return success, errors
|
||||
|
||||
def action_send_and_print(self, from_cron=False):
|
||||
|
||||
@@ -65,6 +65,7 @@ class AccountEdiProxyClientUser(models.Model):
|
||||
_sql_constraints = [
|
||||
('unique_id_client', 'unique(id_client)', 'This id_client is already used on another user.'),
|
||||
('unique_edi_identification', 'unique(edi_identification, proxy_type, edi_mode)', 'This edi identification is already assigned to a user'),
|
||||
('unique_company_proxy', 'unique(company_id, proxy_type, edi_mode)', 'This company has a user already created for this EDI type'),
|
||||
]
|
||||
|
||||
@api.depends('company_id')
|
||||
@@ -189,7 +190,7 @@ class AccountEdiProxyClientUser(models.Model):
|
||||
if 'error' in response:
|
||||
raise UserError(response['error'])
|
||||
|
||||
self.create({
|
||||
return self.create({
|
||||
'id_client': response['id_client'],
|
||||
'company_id': company.id,
|
||||
'proxy_type': proxy_type,
|
||||
|
||||
@@ -52,6 +52,7 @@ EAS_MAPPING = {
|
||||
'CY': {'9928': 'vat'},
|
||||
'CZ': {'9929': 'vat'},
|
||||
'DE': {'9930': 'vat'},
|
||||
'DK': {'0198': 'vat'},
|
||||
'EE': {'9931': 'vat'},
|
||||
'ES': {'9920': 'vat'},
|
||||
'FI': {'0213': 'vat'},
|
||||
@@ -62,6 +63,8 @@ EAS_MAPPING = {
|
||||
'HR': {'9934': 'vat'},
|
||||
'HU': {'9910': 'vat'},
|
||||
'IE': {'9935': 'vat'},
|
||||
'IS': {'0196': 'vat'},
|
||||
'IT': {'0210': 'l10n_it_codice_fiscale'},
|
||||
'LI': {'9936': 'vat'},
|
||||
'LT': {'9937': 'vat'},
|
||||
'LU': {'9938': 'vat'},
|
||||
|
||||
@@ -63,11 +63,14 @@ class AccountMoveSend(models.Model):
|
||||
# EXTENDS 'account'
|
||||
return super()._get_linked_attachments(move) + move.ubl_cii_xml_id
|
||||
|
||||
def _needs_ubl_cii_placeholder(self):
|
||||
return self.enable_ubl_cii_xml and self.checkbox_ubl_cii_xml
|
||||
|
||||
def _get_placeholder_mail_attachments_data(self, move):
|
||||
# EXTENDS 'account'
|
||||
results = super()._get_placeholder_mail_attachments_data(move)
|
||||
|
||||
if self.enable_ubl_cii_xml and self.checkbox_ubl_cii_xml:
|
||||
if self._needs_ubl_cii_placeholder():
|
||||
builder = move.partner_id._get_edi_builder()
|
||||
filename = builder._export_invoice_filename(move)
|
||||
results.append({
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
||||
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': "Peppol",
|
||||
'summary': """
|
||||
This module is used to send/receive documents with PEPPOL
|
||||
""",
|
||||
'description': """
|
||||
- Register as a PEPPOL participant
|
||||
- Send and receive documents via PEPPOL network in Peppol BIS Billing 3.0 format
|
||||
""",
|
||||
'category': 'Accounting/Accounting',
|
||||
'version': '1.0',
|
||||
'depends': [
|
||||
'account_edi_proxy_client',
|
||||
'account_edi_ubl_cii',
|
||||
],
|
||||
'data': [
|
||||
'data/cron.xml',
|
||||
'views/account_move_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'wizard/account_move_send_views.xml',
|
||||
],
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="ir_cron_peppol_get_new_documents" model="ir.cron">
|
||||
<field name="name">PEPPOL: retrieve new documents</field>
|
||||
<field name="interval_number">12</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="model_id" ref="model_account_edi_proxy_client_user"/>
|
||||
<field name="code">model._cron_peppol_get_new_documents()</field>
|
||||
<field name="state">code</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_cron_peppol_get_message_status" model="ir.cron">
|
||||
<field name="name">PEPPOL: update message status</field>
|
||||
<field name="interval_number">12</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="model_id" ref="model_account_edi_proxy_client_user"/>
|
||||
<field name="code">model._cron_peppol_get_message_status()</field>
|
||||
<field name="state">code</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_cron_peppol_get_participant_status" model="ir.cron">
|
||||
<field name="name">PEPPOL: update participant status</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="model_id" ref="model_account_edi_proxy_client_user"/>
|
||||
<field name="code">model._cron_peppol_get_participant_status()</field>
|
||||
<field name="state">code</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import account_edi_proxy_user
|
||||
from . import account_move
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
from . import res_partner
|
||||
@@ -0,0 +1,209 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import fields, models, _
|
||||
from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountEdiProxyClientUser(models.Model):
|
||||
_inherit = 'account_edi_proxy_client.user'
|
||||
|
||||
proxy_type = fields.Selection(
|
||||
selection_add=[('peppol', 'PEPPOL')],
|
||||
)
|
||||
|
||||
def _get_proxy_urls(self):
|
||||
urls = super()._get_proxy_urls()
|
||||
urls['peppol'] = {
|
||||
'prod': '',
|
||||
'test': 'https://l10n-pe-edi.test.odoo.com',
|
||||
}
|
||||
return urls
|
||||
|
||||
def _get_server_url(self, proxy_type=None, edi_mode=None):
|
||||
proxy_type = proxy_type or self.proxy_type
|
||||
if not proxy_type == 'peppol':
|
||||
return super()._get_server_url(proxy_type, edi_mode)
|
||||
|
||||
peppol_param = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'account_peppol.edi.mode', False
|
||||
)
|
||||
if peppol_param == 'test':
|
||||
edi_mode = 'test'
|
||||
|
||||
return super()._get_server_url(proxy_type, edi_mode)
|
||||
|
||||
def _compute_proxy_type(self):
|
||||
# Extends account_edi_proxy_client
|
||||
super()._compute_proxy_type()
|
||||
for user in self:
|
||||
if user.company_id.is_account_peppol_participant:
|
||||
user.proxy_type = 'peppol'
|
||||
|
||||
def _get_proxy_identification(self, company):
|
||||
if not company.peppol_eas or not company.peppol_endpoint:
|
||||
raise UserError(
|
||||
_("Please fill in the EAS code and the Participant ID code in the company's partner form."))
|
||||
return f'{company.peppol_eas}:{company.peppol_endpoint}'
|
||||
|
||||
def _cron_peppol_get_new_documents(self):
|
||||
# Retrieve all new Peppol documents for every edi user in the database
|
||||
edi_users = self.env['account_edi_proxy_client.user'].search(
|
||||
[('company_id.account_peppol_proxy_state', '=', 'active')])
|
||||
|
||||
for edi_user in edi_users:
|
||||
proxy_acks = []
|
||||
try:
|
||||
# request all messages that haven't been acknowledged
|
||||
messages = edi_user._make_request(
|
||||
f"{edi_user._get_server_url()}/api/peppol/1/get_all_documents")
|
||||
except AccountEdiProxyError as e:
|
||||
_logger.error(
|
||||
'Error while receiving the document from Peppol Proxy: %s', e.message)
|
||||
continue
|
||||
|
||||
message_uuids = [
|
||||
message['uuid']
|
||||
for message in messages['messages']
|
||||
if message['direction'] == 'incoming'
|
||||
and message['receiver'] == edi_user.edi_identification
|
||||
]
|
||||
if not message_uuids:
|
||||
continue
|
||||
|
||||
company = edi_user.company_id
|
||||
# retrieve attachments for filtered messages
|
||||
all_messages = edi_user._make_request(
|
||||
f"{edi_user._get_server_url()}/api/peppol/1/get_document",
|
||||
{'message_uuids': message_uuids},
|
||||
)
|
||||
|
||||
for uuid, content in all_messages.items():
|
||||
error_move_vals = {
|
||||
'move_type': 'in_invoice',
|
||||
'peppol_move_state': 'error',
|
||||
'company_id': company.id,
|
||||
'extract_can_show_send_button': False,
|
||||
'peppol_message_uuid': uuid,
|
||||
}
|
||||
if content.get('error'):
|
||||
# in this case there is no attachment that we could add to the account move
|
||||
move = self.env['account.move'].create(error_move_vals)
|
||||
move._message_log(body=_('Error when receiving via Peppol: %s', content['error']['message']))
|
||||
proxy_acks.append(uuid)
|
||||
continue
|
||||
|
||||
enc_key = content["enc_key"]
|
||||
document_content = content["document"]
|
||||
filename = content["filename"]
|
||||
partner_endpoint = content["accounting_supplier_party"]
|
||||
decoded_document = edi_user._decrypt_data(document_content, enc_key)
|
||||
|
||||
journal_id = company.peppol_purchase_journal_id
|
||||
# use the first purchase journal if the Peppol journal is not set up
|
||||
# to create the move anyway
|
||||
if not journal_id:
|
||||
journal_id = self.env['account.journal'].search(
|
||||
[('company_id', '=', company.id), ('type', '=', 'purchase')],
|
||||
limit=1,
|
||||
)
|
||||
|
||||
attachment_vals = {
|
||||
'name': f'{filename}.xml',
|
||||
'raw': decoded_document,
|
||||
'type': 'binary',
|
||||
'mimetype': 'application/xml',
|
||||
}
|
||||
|
||||
try:
|
||||
attachment = self.env['ir.attachment'].create(attachment_vals)
|
||||
move = journal_id\
|
||||
.with_context(
|
||||
default_move_type='in_invoice',
|
||||
default_peppol_move_state='done',
|
||||
default_extract_can_show_send_button=False,
|
||||
default_peppol_message_uuid=uuid,
|
||||
)\
|
||||
._create_document_from_attachment(attachment.id)
|
||||
if partner_endpoint:
|
||||
move._message_log(body=_(
|
||||
'Peppol document has been received successfully. Sender endpoint: %s', partner_endpoint))
|
||||
else:
|
||||
move._message_log(
|
||||
body=_('Peppol document has been received successfully'))
|
||||
except Exception:
|
||||
# if the invoice creation fails for any reason,
|
||||
# we want to create an empty invoice with the attachment
|
||||
move = self.env['account.move'].create(error_move_vals)
|
||||
attachment_vals.update({
|
||||
'res_model': 'account.move',
|
||||
'res_id': move.id,
|
||||
})
|
||||
self.env['ir.attachment'].create(attachment_vals)
|
||||
if partner_endpoint:
|
||||
move._message_log(body=_(
|
||||
'Failed to import a Peppol document. Sender endpoint: %s', partner_endpoint))
|
||||
else:
|
||||
move._message_log(body=_('Failed to import a Peppol document.'))
|
||||
|
||||
proxy_acks.append(uuid)
|
||||
|
||||
self.env.cr.commit()
|
||||
if proxy_acks:
|
||||
edi_user._make_request(
|
||||
f"{edi_user._get_server_url()}/api/peppol/1/ack",
|
||||
{'message_uuids': proxy_acks},
|
||||
)
|
||||
|
||||
def _cron_peppol_get_message_status(self):
|
||||
edi_users = self.env['account_edi_proxy_client.user'].search(
|
||||
[('company_id.account_peppol_proxy_state', '=', 'active')])
|
||||
|
||||
for edi_user in edi_users:
|
||||
edi_user_moves = self.env['account.move'].search([
|
||||
('peppol_move_state', '=', 'processing'),
|
||||
('company_id', '=', edi_user.company_id.id),
|
||||
])
|
||||
if not edi_user_moves:
|
||||
continue
|
||||
|
||||
message_uuids = {move.peppol_message_uuid: move for move in edi_user_moves}
|
||||
messages_to_process = edi_user._make_request(
|
||||
f"{edi_user._get_server_url()}/api/peppol/1/get_document",
|
||||
{'message_uuids': list(message_uuids.keys())},
|
||||
)
|
||||
|
||||
for uuid, content in messages_to_process.items():
|
||||
move = message_uuids[uuid]
|
||||
if content.get('error'):
|
||||
move.peppol_move_state = 'error'
|
||||
move._message_log(body=_('Peppol error: %s', content['error']['message']))
|
||||
continue
|
||||
move.peppol_move_state = content['state']
|
||||
move._message_log(body=_('Peppol update: %s', content['state']))
|
||||
|
||||
if message_uuids:
|
||||
edi_user._make_request(
|
||||
f"{edi_user._get_server_url()}/api/peppol/1/ack",
|
||||
{'message_uuids': list(message_uuids.keys())},
|
||||
)
|
||||
|
||||
def _cron_peppol_get_participant_status(self):
|
||||
edi_users = self.env['account_edi_proxy_client.user'].search(
|
||||
[('company_id.account_peppol_proxy_state', '=', 'pending')])
|
||||
|
||||
for edi_user in edi_users:
|
||||
try:
|
||||
proxy_user = edi_user._make_request(
|
||||
f"{edi_user._get_server_url()}/api/peppol/1/participant_status")
|
||||
except AccountEdiProxyError as e:
|
||||
_logger.error('Error while updating Peppol participant status: %s', e)
|
||||
continue
|
||||
|
||||
if proxy_user['peppol_state'] != 'draft':
|
||||
edi_user.company_id.account_peppol_proxy_state = proxy_user['peppol_state']
|
||||
@@ -0,0 +1,30 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
peppol_message_uuid = fields.Char(string='PEPPOL message ID')
|
||||
peppol_move_state = fields.Selection(
|
||||
selection=[
|
||||
('to_send', 'To Send'),
|
||||
('processing', 'Processing'),
|
||||
('canceled', 'Canceled'),
|
||||
('done', 'Done'),
|
||||
('error', 'Error'),
|
||||
],
|
||||
string='PEPPOL status',
|
||||
copy=False,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
def action_cancel_peppol_documents(self):
|
||||
# if the peppol_move_state is processing/done
|
||||
# then it means it has been already sent to peppol proxy and we can't cancel
|
||||
if any(move.peppol_move_state in {'processing', 'done'} for move in self):
|
||||
raise UserError(_("Cannot cancel an entry that has already been sent to PEPPOL"))
|
||||
self.peppol_move_state = 'canceled'
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
account_peppol_attachment_ids = fields.Many2many(
|
||||
comodel_name='ir.attachment',
|
||||
string='Peppol Identification Documents',
|
||||
)
|
||||
account_peppol_proxy_state = fields.Selection(
|
||||
selection=[
|
||||
('not_registered', 'Not registered'),
|
||||
('pending', 'Pending'),
|
||||
('manually_approved', 'Approved'),
|
||||
('active', 'Active'),
|
||||
('rejected', 'Rejected'),
|
||||
],
|
||||
string='PEPPOL status',
|
||||
compute='_compute_account_peppol_proxy_state', required=True, readonly=False, store=True, precompute=True,
|
||||
)
|
||||
is_account_peppol_participant = fields.Boolean(string='PEPPOL Participant')
|
||||
peppol_eas = fields.Selection(related='partner_id.peppol_eas', readonly=False)
|
||||
peppol_endpoint = fields.Char(related='partner_id.peppol_endpoint', readonly=False)
|
||||
peppol_purchase_journal_id = fields.Many2one(
|
||||
comodel_name='account.journal',
|
||||
string='PEPPOL Purchase Journal',
|
||||
domain=[('type', '=', 'purchase')],
|
||||
compute='_compute_peppol_purchase_journal_id', store=True, readonly=False,
|
||||
)
|
||||
|
||||
@api.depends('is_account_peppol_participant')
|
||||
def _compute_account_peppol_proxy_state(self):
|
||||
for company in self:
|
||||
if not company.account_peppol_proxy_state:
|
||||
company.account_peppol_proxy_state = 'not_registered'
|
||||
|
||||
@api.depends('is_account_peppol_participant')
|
||||
def _compute_peppol_purchase_journal_id(self):
|
||||
for company in self:
|
||||
if company.is_account_peppol_participant and not company.peppol_purchase_journal_id:
|
||||
company.peppol_purchase_journal_id = self.env['account.journal'].search(
|
||||
[('company_id', '=', company.id), ('type', '=', 'purchase')],
|
||||
limit=1,
|
||||
)
|
||||
else:
|
||||
company.peppol_purchase_journal_id = False
|
||||
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from base64 import b64encode
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
account_peppol_eas = fields.Selection(related='company_id.peppol_eas', readonly=False)
|
||||
account_peppol_endpoint = fields.Char(related='company_id.peppol_endpoint', readonly=False)
|
||||
account_peppol_proxy_state = fields.Selection(related='company_id.account_peppol_proxy_state')
|
||||
account_peppol_purchase_journal_id = fields.Many2one(related='company_id.peppol_purchase_journal_id', readonly=False)
|
||||
account_peppol_attachment_ids = fields.Many2many(
|
||||
comodel_name='ir.attachment',
|
||||
string='Peppol Identification Documents',
|
||||
related='company_id.account_peppol_attachment_ids', readonly=False,
|
||||
)
|
||||
is_account_peppol_eligible = fields.Boolean(
|
||||
string='PEPPOL eligible',
|
||||
compute='_compute_is_account_peppol_eligible',
|
||||
) # technical field used for showing the Peppol settings conditionally
|
||||
is_account_peppol_participant = fields.Boolean(
|
||||
string='Use PEPPOL',
|
||||
related='company_id.is_account_peppol_participant', readonly=False,
|
||||
help='Register as a PEPPOL user',
|
||||
)
|
||||
|
||||
@api.depends("company_id.country_id")
|
||||
def _compute_is_account_peppol_eligible(self):
|
||||
# we want to show Peppol settings only to BE and LU customers at first
|
||||
# but keeping an option to see them for testing purposes using a config param
|
||||
for config in self:
|
||||
peppol_param = config.env['ir.config_parameter'].sudo().get_param(
|
||||
'account_peppol.edi.mode', False
|
||||
)
|
||||
config.is_account_peppol_eligible = config.company_id.country_id.code in {'BE', 'LU'} \
|
||||
or peppol_param == 'test'
|
||||
|
||||
def button_create_peppol_proxy_user(self):
|
||||
self.ensure_one()
|
||||
|
||||
if self.account_peppol_proxy_state != 'not_registered':
|
||||
raise UserError(
|
||||
_('Cannot register a user with a %s application', self.account_peppol_proxy_state))
|
||||
|
||||
if not self.company_id.account_peppol_attachment_ids:
|
||||
raise ValidationError(
|
||||
_('Please upload a document that would help verifying your application'))
|
||||
edi_proxy_client = self.env['account_edi_proxy_client.user']
|
||||
edi_identification = edi_proxy_client._get_proxy_identification(self.company_id)
|
||||
edi_user = edi_proxy_client.sudo()._register_proxy_user(
|
||||
self.company_id, 'peppol', 'prod', edi_identification)
|
||||
|
||||
params = {'documents': []}
|
||||
for attachment in self.company_id.account_peppol_attachment_ids:
|
||||
params['documents'].append((attachment.name, b64encode(attachment.raw).decode()))
|
||||
|
||||
try:
|
||||
response = edi_user._make_request(
|
||||
f"{edi_user._get_server_url()}/api/peppol/1/activate_participant",
|
||||
params=params,
|
||||
)
|
||||
except AccountEdiProxyError as e:
|
||||
raise UserError(e.message)
|
||||
if 'error' in response:
|
||||
raise UserError(response['error'])
|
||||
|
||||
self.company_id.account_peppol_proxy_state = 'pending'
|
||||
# we don't need to store the attachments once they've been sent to the proxy
|
||||
self.company_id.account_peppol_attachment_ids.unlink()
|
||||
@@ -0,0 +1,85 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import requests
|
||||
from lxml import etree
|
||||
from hashlib import md5
|
||||
from urllib import parse
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
account_peppol_is_endpoint_valid = fields.Boolean(
|
||||
string="PEPPOL endpoint validity",
|
||||
help="The partner's EAS code and PEPPOL endpoint are valid",
|
||||
compute="_compute_account_peppol_is_endpoint_valid", store=True,
|
||||
copy=False,
|
||||
)
|
||||
account_peppol_validity_last_check = fields.Date(
|
||||
string="Checked on",
|
||||
help="Last Peppol endpoint verification",
|
||||
readonly=True,
|
||||
copy=False,
|
||||
)
|
||||
account_peppol_verification_label = fields.Selection(
|
||||
selection=[
|
||||
('not_verified', 'Not verified yet'),
|
||||
('not_valid', 'Not valid'),
|
||||
('valid', 'Valid'),
|
||||
],
|
||||
string='Peppol endpoint validity',
|
||||
compute='_compute_account_peppol_verification_label',
|
||||
copy=False,
|
||||
) # field to compute the label to show for partner endpoint
|
||||
|
||||
@api.depends('peppol_eas', 'peppol_endpoint')
|
||||
def _compute_account_peppol_is_endpoint_valid(self):
|
||||
# Every change in peppol_eas or peppol_endpoint should set the validity back to False
|
||||
self.account_peppol_is_endpoint_valid = False
|
||||
|
||||
@api.depends('account_peppol_is_endpoint_valid', 'account_peppol_validity_last_check')
|
||||
def _compute_account_peppol_verification_label(self):
|
||||
for partner in self:
|
||||
if not partner.account_peppol_validity_last_check:
|
||||
partner.account_peppol_verification_label = 'not_verified'
|
||||
elif partner.account_peppol_is_endpoint_valid:
|
||||
partner.account_peppol_verification_label = 'valid'
|
||||
else:
|
||||
partner.account_peppol_verification_label = 'not_valid'
|
||||
|
||||
def button_account_peppol_check_partner_endpoint(self):
|
||||
""" A basic check for whether a participant is reachable at the given
|
||||
Peppol participant ID - peppol_eas:peppol_endpoint (ex: '9999:test')
|
||||
The SML (Service Metadata Locator) assigns a DNS name to each peppol participant.
|
||||
This DNS name resolves into the SMP (Service Metadata Publisher) of the participant.
|
||||
The DNS address is of the following form:
|
||||
- "http://B-" + hexstring(md5(lowercase(ID-VALUE))) + "." + ID-SCHEME + "." + SML-ZONE-NAME + "/" + url_encoded(ID-SCHEME + "::" + ID-VALUE)
|
||||
(ref:https://peppol.helger.com/public/locale-en_US/menuitem-docs-doc-exchange)
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
participant = f'{self.peppol_eas}:{self.peppol_endpoint}'.lower()
|
||||
hash_participant = md5(participant.lower().encode()).hexdigest()
|
||||
endpoint_participant = parse.quote_plus(f"iso6523-actorid-upis::{participant}")
|
||||
smp_url = f"http://B-{hash_participant}.iso6523-actorid-upis.edelivery.tech.ec.europa.eu/{endpoint_participant}"
|
||||
self.account_peppol_validity_last_check = fields.Date.context_today(self)
|
||||
try:
|
||||
response = requests.get(smp_url, timeout=TIMEOUT)
|
||||
except requests.exceptions.ConnectionError:
|
||||
self.account_peppol_is_endpoint_valid = False
|
||||
else:
|
||||
if response.status_code != 200:
|
||||
self.account_peppol_is_endpoint_valid = False
|
||||
return
|
||||
|
||||
participant_info = etree.XML(response.content)
|
||||
participant_identifier = participant_info.find('{*}ParticipantIdentifier').text
|
||||
if participant != participant_identifier:
|
||||
self.account_peppol_is_endpoint_valid = False
|
||||
return
|
||||
|
||||
self.account_peppol_is_endpoint_valid = True
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="account_peppol_view_move_form" model="ir.ui.view">
|
||||
<field name="name">account.peppol.view.move.form</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="priority">30</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<header position="inside">
|
||||
<button name="action_cancel_peppol_documents"
|
||||
type="object"
|
||||
class="btn btn-secondary"
|
||||
string="Cancel PEPPOL"
|
||||
attrs="{'invisible': ['|', ('peppol_move_state', '!=', 'to_send'), ('state', '=', 'draft')]}"/>
|
||||
</header>
|
||||
|
||||
<xpath expr="//div[@name='journal_div']" position="after">
|
||||
<label for="peppol_move_state"
|
||||
attrs="{'invisible': ['|', ('peppol_move_state', '=', False), ('state', '=', 'draft')]}"/>
|
||||
<div name="peppol_div"
|
||||
class="d-flex"
|
||||
attrs="{'invisible': ['|', ('peppol_move_state', '=', False), ('state', '=', 'draft')]}">
|
||||
|
||||
<field name="peppol_move_state" class="oe_inline"/>
|
||||
<span class="text-muted mx-3"
|
||||
attrs="{'invisible': [('peppol_move_state', '!=', 'to_send')]}">
|
||||
The invoice will be sent automatically to PEPPOL
|
||||
</span>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="account_peppol_view_out_invoice_tree_inherit" model="ir.ui.view">
|
||||
<field name="name">account.move.in.invoice.tree.inherit</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_out_invoice_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="state" position="before">
|
||||
<field name="peppol_move_state" optional="hide"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="account_peppol_view_out_credit_note_tree_inherit" model="ir.ui.view">
|
||||
<field name="name">account.move.credit.note.tree.inherit</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_out_credit_note_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="state" position="before">
|
||||
<field name="peppol_move_state" optional="hide"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="account_peppol_view_in_invoice_bill_tree_inherit" model="ir.ui.view">
|
||||
<field name="name">account.out.invoice.tree.inherit</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_in_invoice_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="state" position="before">
|
||||
<field name="peppol_move_state" optional="hide"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="account_peppol_view_account_invoice_filter" model="ir.ui.view">
|
||||
<field name="name">account.invoice.select.inherit</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_account_invoice_filter"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//search/group/filter[@name='status']" position="after">
|
||||
<filter string="PEPPOL state" name="peppol_move_state" context="{'group_by': 'peppol_move_state'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.proxy.user</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//block[@id='analytic']" position="after">
|
||||
<field name="is_account_peppol_eligible" invisible="1"/>
|
||||
<div attrs="{'invisible': [('is_account_peppol_eligible', '=', False)]}">
|
||||
<h2>PEPPOL Electronic Document Invoicing</h2>
|
||||
<div class="row mt16 o_settings_container" id='account_peppol'>
|
||||
<div class="col-12 col-lg-12 o_setting_box">
|
||||
<field name="account_peppol_proxy_state" invisible="1"/>
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="is_account_peppol_participant"
|
||||
attrs="{'invisible': [('account_peppol_proxy_state', 'not in', (False, 'not_registered'))]}"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<div attrs="{'invisible': [('account_peppol_proxy_state', '!=', 'not_registered')]}">
|
||||
<label for="is_account_peppol_participant" string='Use PEPPOL Invoicing'/>
|
||||
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific."/>
|
||||
<div class="text-muted">
|
||||
Once you select this option and confirm, we will review your application.
|
||||
You will be able to start sending and receiving documents via PEPPOL once your participation is approved.
|
||||
We will notify you once we have reviewed your application.
|
||||
</div>
|
||||
<div class="pt-3 pb-3"
|
||||
attrs="{'invisible': [('is_account_peppol_participant', '=', False)]}">
|
||||
<b>
|
||||
Please verify the EAS code and the Endpoint (Participant ID) below
|
||||
before confirming the registration!
|
||||
</b>
|
||||
<div class="row pt-3">
|
||||
<label string="Peppol EAS"
|
||||
for="account_peppol_eas"
|
||||
class="col-lg-3 o_light_label"/>
|
||||
<field name="account_peppol_eas"/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label string="Peppol Endpoint"
|
||||
for="account_peppol_eas"
|
||||
class="col-lg-3 o_light_label"/>
|
||||
<field name="account_peppol_endpoint"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-group"
|
||||
attrs="{'invisible': ['|',
|
||||
('is_account_peppol_participant', '=', False),
|
||||
('account_peppol_proxy_state', 'in', ('rejected', 'not_registered'))]}">
|
||||
<div class="row">
|
||||
<label string="Incoming Invoices Journal"
|
||||
for="account_peppol_purchase_journal_id"
|
||||
class="col-lg-3 o_light_label"/>
|
||||
<field name="account_peppol_purchase_journal_id"
|
||||
attrs="{'required': [
|
||||
('is_account_peppol_participant', '=', True),
|
||||
('account_peppol_proxy_state', 'not in', ('rejected', 'not_registered')),
|
||||
]}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-group pt-3"
|
||||
attrs="{'invisible': ['|',
|
||||
('is_account_peppol_participant', '=', False),
|
||||
('account_peppol_proxy_state', '!=', 'not_registered')]}">
|
||||
<span>
|
||||
Upload a document to verify Company information.
|
||||
</span>
|
||||
<div class="row mt-3">
|
||||
<field name="account_peppol_attachment_ids"
|
||||
widget="many2many_binary"
|
||||
nolabel="1"
|
||||
string="Upload documents"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-3 pb-3"
|
||||
attrs="{'invisible': [('account_peppol_proxy_state', 'in', (False, 'not_registered'))]}">
|
||||
Application status:
|
||||
<b>
|
||||
<field name="account_peppol_proxy_state"
|
||||
decoration-danger="account_peppol_proxy_state == 'rejected'"
|
||||
decoration-info="account_peppol_proxy_state == 'active'"/>
|
||||
</b>
|
||||
</div>
|
||||
<div class="mt-5"
|
||||
attrs="{'invisible': ['|',
|
||||
('is_account_peppol_participant', '=', False),
|
||||
('account_peppol_proxy_state', '!=', 'not_registered')]}">
|
||||
<div class="mb-3">
|
||||
By clicking the "Confirm" button, I accept that Odoo may process my e-invoices.
|
||||
</div>
|
||||
<button name="button_create_peppol_proxy_user"
|
||||
type="object"
|
||||
string="Confirm"
|
||||
class="btn btn-primary"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_partner_form_account_peppol" model="ir.ui.view">
|
||||
<field name="name">res.partner.form.account.peppol</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="priority">20</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<data>
|
||||
<xpath expr="//field[@name='peppol_endpoint']" position="after">
|
||||
<field name="account_peppol_is_endpoint_valid" invisible="1"/>
|
||||
<field name="account_peppol_validity_last_check" invisible="1"/>
|
||||
<label for="account_peppol_verification_label"
|
||||
attrs="{'invisible': ['|',
|
||||
('ubl_cii_format', 'in', (False, 'facturx')),
|
||||
('peppol_endpoint', '=', False),
|
||||
]}"/>
|
||||
<div class="row"
|
||||
attrs="{'invisible': ['|',
|
||||
('ubl_cii_format', 'in', (False, 'facturx')),
|
||||
('peppol_endpoint', '=', False),
|
||||
]}">
|
||||
<div class="col-4">
|
||||
<field name="account_peppol_verification_label"/>
|
||||
</div>
|
||||
<div class="col-8 pt-0">
|
||||
<button name="button_account_peppol_check_partner_endpoint"
|
||||
class="btn btn-secondary pt-0"
|
||||
type="object"
|
||||
string="Verify"
|
||||
help="Verify partner's PEPPOL endpoint"/>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</data>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import account_move_send
|
||||
@@ -0,0 +1,162 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from base64 import b64encode
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AccountMoveSend(models.Model):
|
||||
_inherit = 'account.move.send'
|
||||
|
||||
checkbox_send_peppol = fields.Boolean(
|
||||
string='Send via PEPPOL',
|
||||
compute='_compute_checkbox_send_peppol', store=True, readonly=False,
|
||||
help='Send the invoice via PEPPOL',
|
||||
)
|
||||
peppol_proxy_state = fields.Selection(related='company_id.account_peppol_proxy_state')
|
||||
peppol_warning = fields.Char(
|
||||
string="Warning",
|
||||
compute="_compute_peppol_warning",
|
||||
) # technical field needed for computing a warning text about the peppol configuration
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# COMPUTE METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.depends('checkbox_ubl_cii_xml', 'enable_ubl_cii_xml')
|
||||
def _compute_checkbox_send_peppol(self):
|
||||
# we can't send via peppol if checkbox_ubl_cii_xml is unchecked
|
||||
for wizard in self:
|
||||
if not (wizard.checkbox_ubl_cii_xml and wizard.enable_ubl_cii_xml):
|
||||
wizard.checkbox_send_peppol = False
|
||||
|
||||
@api.depends('checkbox_send_peppol')
|
||||
def _compute_mail_attachments_widget(self):
|
||||
# EXTENDS 'account' - add depends
|
||||
super()._compute_mail_attachments_widget()
|
||||
|
||||
@api.depends('move_ids')
|
||||
def _compute_peppol_warning(self):
|
||||
for wizard in self:
|
||||
invalid_partners = wizard.move_ids.partner_id.filtered(
|
||||
lambda partner: not partner.account_peppol_is_endpoint_valid or partner.ubl_cii_format in {False, 'facturx'})
|
||||
if not invalid_partners:
|
||||
wizard.peppol_warning = False
|
||||
else:
|
||||
names = ', '.join(invalid_partners[:5].mapped('display_name'))
|
||||
wizard.peppol_warning = _("The following partners are not correctly configured to receive Peppol documents. "
|
||||
"Please check and verify their Peppol endpoint and the Electronic Invoicing format: "
|
||||
"%s", names)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# ATTACHMENTS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _needs_ubl_cii_placeholder(self):
|
||||
return super()._needs_ubl_cii_placeholder() and not self.checkbox_send_peppol
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# BUSINESS ACTIONS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def action_send_and_print(self, from_cron=False):
|
||||
# Extends the wizard to mark the move to be sent via PEPPOL first
|
||||
self.ensure_one()
|
||||
|
||||
if self.checkbox_send_peppol and self.peppol_proxy_state == 'active':
|
||||
for move in self.move_ids:
|
||||
if not move.peppol_move_state:
|
||||
move.peppol_move_state = 'to_send'
|
||||
|
||||
return super().action_send_and_print(from_cron)
|
||||
|
||||
def _call_web_service(self, prepared_data_list):
|
||||
# Overrides 'account'
|
||||
if not self.checkbox_send_peppol:
|
||||
return super()._call_web_service(prepared_data_list)
|
||||
|
||||
params = {'documents': []}
|
||||
all_moves = self.env['account.move']
|
||||
|
||||
if not prepared_data_list:
|
||||
# if a user tries to resend an invoice that has a peppol error state,
|
||||
# the prepared_data_list is going to be empty, as the xml has been aready generated
|
||||
error_moves = self.move_ids.filtered(lambda m: m.peppol_move_state == 'error')
|
||||
if not error_moves:
|
||||
return
|
||||
|
||||
for error_move in error_moves:
|
||||
receiver_identification = f"{error_move.partner_id.peppol_eas}:{error_move.partner_id.peppol_endpoint}"
|
||||
if not error_move.ubl_cii_xml_id:
|
||||
# this happens when a user manually deletes the xml file
|
||||
# but doesn't delete the pdf file
|
||||
# they should delete both if they want to regenerate attachments
|
||||
raise UserError(_('The following invoice does not have an XML attachment: %s', error_move.name))
|
||||
|
||||
params['documents'].append({
|
||||
'filename': error_move.name,
|
||||
'receiver': receiver_identification,
|
||||
'ubl': b64encode(error_move.ubl_cii_xml_id.raw).decode(),
|
||||
})
|
||||
all_moves |= error_move
|
||||
|
||||
for move, prepared_data in prepared_data_list:
|
||||
if not prepared_data:
|
||||
move.peppol_move_state = False
|
||||
raise UserError(_('Please select the UBL format'))
|
||||
|
||||
if move.peppol_move_state not in ('to_send', 'error'):
|
||||
continue
|
||||
|
||||
xml_file = prepared_data['ubl_cii_xml_attachment_values']['raw']
|
||||
filename = prepared_data['ubl_cii_xml_attachment_values']['name']
|
||||
|
||||
if not move.partner_id.peppol_eas or not move.partner_id.peppol_endpoint:
|
||||
# should never happen but in case it does, we need to handle it
|
||||
move.peppol_move_state = 'error'
|
||||
move._message_log(body=_('The partner is missing Peppol EAS and/or Endpoint identifier.'))
|
||||
continue
|
||||
|
||||
if not move.partner_id.account_peppol_is_endpoint_valid:
|
||||
move.peppol_move_state = 'error'
|
||||
move._message_log(body=_('Please verify partner configuration in partner settings.'))
|
||||
continue
|
||||
|
||||
receiver_identification = f"{move.partner_id.peppol_eas}:{move.partner_id.peppol_endpoint}"
|
||||
params['documents'].append({
|
||||
'filename': filename,
|
||||
'receiver': receiver_identification,
|
||||
'ubl': b64encode(xml_file).decode(),
|
||||
})
|
||||
all_moves |= move
|
||||
|
||||
edi_user = self.env['account_edi_proxy_client.user'].search([
|
||||
('company_id', '=', self.company_id.id),
|
||||
('proxy_type', '=', 'peppol'),
|
||||
])
|
||||
|
||||
try:
|
||||
response = edi_user._make_request(
|
||||
f"{edi_user._get_server_url()}/api/peppol/1/send_document",
|
||||
params=params,
|
||||
)
|
||||
if response.get('error'):
|
||||
# at the moment the only error that can happen here is ParticipantNotReady error
|
||||
all_moves._message_log_batch(bodies=dict((move.id, response['error']['message']) for move in all_moves))
|
||||
all_moves.peppol_move_state = 'error'
|
||||
raise UserError(response['error']['message'])
|
||||
except AccountEdiProxyError as e:
|
||||
all_moves._message_log_batch(bodies=dict((move.id, e.message) for move in all_moves))
|
||||
all_moves.peppol_move_state = 'error'
|
||||
raise UserError(e.message)
|
||||
else:
|
||||
# the response only contains message uuids,
|
||||
# so we have to rely on the order to connect peppol messages to account.move
|
||||
for i, move in enumerate(all_moves):
|
||||
move.peppol_message_uuid = response['messages'][i]['message_uuid']
|
||||
move.peppol_move_state = 'processing'
|
||||
log_message = _('The document has been sent to the Peppol Access Point for processing')
|
||||
all_moves._message_log_batch(bodies=dict((move.id, log_message) for move in all_moves))
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record model="ir.ui.view" id="account_move_send_inherit_account_peppol">
|
||||
<field name="name">account.move.send.form.inherit.peppol</field>
|
||||
<field name="model">account.move.send</field>
|
||||
<field name="inherit_id" ref="account.account_move_send_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='standard_options']" position="before">
|
||||
<field name="peppol_proxy_state" invisible="1"/>
|
||||
<div class="alert alert-warning text-center" role="alert" attrs="{
|
||||
'invisible': ['|', '|',
|
||||
('peppol_proxy_state', '!=', 'active'),
|
||||
('checkbox_send_peppol', '!=', True),
|
||||
('peppol_warning', '=', False),
|
||||
]}">
|
||||
<field name="peppol_warning"/>
|
||||
</div>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='option_send_by_post']" position='before'>
|
||||
<div name="option_peppol"
|
||||
attrs="{'invisible': ['|', ('peppol_proxy_state', '!=', 'active'), ('enable_ubl_cii_xml', '=', False)]}">
|
||||
<field name="checkbox_send_peppol" attrs="{'readonly': [('checkbox_ubl_cii_xml', '=', False)]}"/>
|
||||
<b>
|
||||
<label for="checkbox_send_peppol"/>
|
||||
</b>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user