[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:
aliya
2023-05-11 18:25:03 +02:00
parent a19cdd6e27
commit be00ef8e37
19 changed files with 967 additions and 6 deletions
+19 -4
View File
@@ -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({
+5
View File
@@ -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
+27
View File
@@ -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',
}
+32
View File
@@ -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>
+8
View File
@@ -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>
+4
View File
@@ -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>