diff --git a/sale_order_ubl/README.rst b/sale_order_ubl/README.rst
new file mode 100644
index 0000000000..77e64a8957
--- /dev/null
+++ b/sale_order_ubl/README.rst
@@ -0,0 +1,58 @@
+.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png
+ :target: https://www.gnu.org/licenses/agpl
+ :alt: License: AGPL-3
+
+==============
+Sale Order UBL
+==============
+
+This module adds support for UBL, the `Universal Business Language (UBL) `_ standard,
+on sale orders. The UBL 2.1 standard became the
+`ISO/IEC 19845 `_ standard
+in December 2015 (cf the `official announce `_).
+
+With this module, when you generate the sale order report:
+
+* on a draft/sent quotation, the PDF file will have an embedded XML *Quotation* file compliant with the UBL 2.1 or 2.0 standard.
+
+* on a confirmed sale order, the PDF file will have an embedded XML *Order Response Simple* file compliant with the UBL 2.1 or 2.0 standard.
+
+Usage
+=====
+
+.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas
+ :alt: Try me on Runbot
+ :target: https://runbot.odoo-community.org/runbot/226/11.0
+
+Bug Tracker
+===========
+
+
+Bugs are tracked on `GitHub Issues
+`_. In case of trouble, please
+check there if your issue has already been reported. If you spotted it first,
+help us smash it by providing detailed and welcomed feedback.
+
+Credits
+=======
+
+Contributors
+------------
+
+* Alexis de Lattre
+* Andrea Stirpe
+
+Maintainer
+----------
+
+.. image:: https://odoo-community.org/logo.png
+ :alt: Odoo Community Association
+ :target: https://odoo-community.org
+
+This module is maintained by the OCA.
+
+OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+To contribute to this module, please visit https://odoo-community.org.
diff --git a/sale_order_ubl/__init__.py b/sale_order_ubl/__init__.py
new file mode 100644
index 0000000000..31660d6a96
--- /dev/null
+++ b/sale_order_ubl/__init__.py
@@ -0,0 +1,3 @@
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from . import models
diff --git a/sale_order_ubl/__manifest__.py b/sale_order_ubl/__manifest__.py
new file mode 100644
index 0000000000..07388e6593
--- /dev/null
+++ b/sale_order_ubl/__manifest__.py
@@ -0,0 +1,15 @@
+# © 2016-2017 Akretion (Alexis de Lattre )
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+{
+ 'name': 'Sale Order UBL',
+ 'version': '11.0.1.0.0',
+ 'category': 'Sales',
+ 'license': 'AGPL-3',
+ 'summary': 'Embed UBL XML file inside the PDF quotation',
+ 'author': 'Akretion,Odoo Community Association (OCA)',
+ 'website': 'https://github.com/OCA/edi/',
+ 'depends': ['sale', 'base_ubl'],
+ 'data': [],
+ 'installable': True,
+}
diff --git a/sale_order_ubl/models/__init__.py b/sale_order_ubl/models/__init__.py
new file mode 100644
index 0000000000..8e6be3f57b
--- /dev/null
+++ b/sale_order_ubl/models/__init__.py
@@ -0,0 +1,4 @@
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from . import sale
+from . import report
diff --git a/sale_order_ubl/models/report.py b/sale_order_ubl/models/report.py
new file mode 100644
index 0000000000..e8491af48c
--- /dev/null
+++ b/sale_order_ubl/models/report.py
@@ -0,0 +1,24 @@
+# © 2016-2017 Akretion (Alexis de Lattre )
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import api, models
+
+
+class IrActionsReport(models.Model):
+ _inherit = "ir.actions.report"
+
+ @api.multi
+ def render_qweb_pdf(self, res_ids=None, data=None):
+ """We go through that method when the PDF is generated for the 1st
+ time and also when it is read from the attachment.
+ This method is specific to QWeb"""
+ pdf_content = super(IrActionsReport, self).render_qweb_pdf(
+ res_ids, data)
+ if (
+ len(self) == 1 and
+ self.report_name == 'sale.report_saleorder' and
+ len(res_ids) == 1 and
+ not self._context.get('no_embedded_ubl_xml')):
+ order = self.env['sale.order'].browse(res_ids[0])
+ pdf_content = order.embed_ubl_xml_in_pdf(pdf_content=pdf_content)
+ return pdf_content
diff --git a/sale_order_ubl/models/sale.py b/sale_order_ubl/models/sale.py
new file mode 100644
index 0000000000..4ae6c5d478
--- /dev/null
+++ b/sale_order_ubl/models/sale.py
@@ -0,0 +1,194 @@
+# © 2016-2017 Akretion (Alexis de Lattre )
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import api, fields, models
+from lxml import etree
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class SaleOrder(models.Model):
+ _name = 'sale.order'
+ _inherit = ['sale.order', 'base.ubl']
+
+ @api.model
+ def get_quotation_states(self):
+ return ['draft', 'sent']
+
+ @api.model
+ def get_order_states(self):
+ return ['sale', 'done']
+
+ @api.multi
+ def _ubl_add_header(self, doc_type, parent_node, ns, version='2.1'):
+ now_utc = fields.Datetime.now()
+ date = now_utc[:10]
+ time = now_utc[11:]
+ ubl_version = etree.SubElement(
+ parent_node, ns['cbc'] + 'UBLVersionID')
+ ubl_version.text = version
+ doc_id = etree.SubElement(parent_node, ns['cbc'] + 'ID')
+ doc_id.text = self.name
+ issue_date = etree.SubElement(parent_node, ns['cbc'] + 'IssueDate')
+ issue_date.text = date
+ if doc_type == 'quotation':
+ issue_time = etree.SubElement(parent_node, ns['cbc'] + 'IssueTime')
+ issue_time.text = time
+ if self.note:
+ note = etree.SubElement(parent_node, ns['cbc'] + 'Note')
+ note.text = self.note
+ if doc_type == 'quotation':
+ doc_currency = etree.SubElement(
+ parent_node, ns['cbc'] + 'PricingCurrencyCode')
+ doc_currency.text = self.currency_id.name
+
+ @api.multi
+ def _ubl_add_quoted_monetary_total(self, parent_node, ns, version='2.1'):
+ monetary_total = etree.SubElement(
+ parent_node, ns['cac'] + 'QuotedMonetaryTotal')
+ line_total = etree.SubElement(
+ monetary_total, ns['cbc'] + 'LineExtensionAmount',
+ currencyID=self.currency_id.name)
+ line_total.text = str(self.amount_untaxed)
+ tax_inclusive_amount = etree.SubElement(
+ monetary_total, ns['cbc'] + 'TaxInclusiveAmount',
+ currencyID=self.currency_id.name)
+ tax_inclusive_amount.text = str(self.amount_total)
+ payable_amount = etree.SubElement(
+ monetary_total, ns['cbc'] + 'PayableAmount',
+ currencyID=self.currency_id.name)
+ payable_amount.text = str(self.amount_total)
+
+ @api.multi
+ def _ubl_add_quotation_line(
+ self, parent_node, oline, line_number, ns, version='2.1'):
+ line_root = etree.SubElement(
+ parent_node, ns['cac'] + 'QuotationLine')
+ dpo = self.env['decimal.precision']
+ qty_precision = dpo.precision_get('Product Unit of Measure')
+ price_precision = dpo.precision_get('Product Price')
+ self._ubl_add_line_item(
+ line_number, oline.name, oline.product_id, 'sale',
+ oline.product_uom_qty, oline.product_uom, line_root, ns,
+ currency=self.currency_id, price_subtotal=oline.price_subtotal,
+ qty_precision=qty_precision, price_precision=price_precision,
+ version=version)
+
+ @api.multi
+ def generate_quotation_ubl_xml_etree(self, version='2.1'):
+ nsmap, ns = self._ubl_get_nsmap_namespace(
+ 'Quotation-2', version=version)
+ xml_root = etree.Element('Quotation', nsmap=nsmap)
+ doc_type = 'quotation'
+ self._ubl_add_header(doc_type, xml_root, ns, version=version)
+
+ self._ubl_add_supplier_party(
+ False, self.company_id, 'SellerSupplierParty', xml_root, ns,
+ version=version)
+ if version == '2.1':
+ self._ubl_add_customer_party(
+ self.partner_id, False, 'BuyerCustomerParty', xml_root, ns,
+ version=version)
+ self._ubl_add_delivery(
+ self.partner_shipping_id, xml_root, ns, version=version)
+ if hasattr(self, 'incoterm') and self.incoterm:
+ self._ubl_add_delivery_terms(
+ self.incoterm, xml_root, ns, version=version)
+ self._ubl_add_quoted_monetary_total(xml_root, ns, version=version)
+
+ line_number = 0
+ for oline in self.order_line:
+ line_number += 1
+ self._ubl_add_quotation_line(
+ xml_root, oline, line_number, ns, version=version)
+ return xml_root
+
+ @api.multi
+ def generate_order_response_simple_ubl_xml_etree(self, version='2.1'):
+ nsmap, ns = self._ubl_get_nsmap_namespace(
+ 'OrderResponseSimple-2', version=version)
+ xml_root = etree.Element('OrderResponseSimple', nsmap=nsmap)
+ doc_type = 'order'
+ self._ubl_add_header(doc_type, xml_root, ns, version=version)
+
+ accepted_indicator = etree.SubElement(
+ xml_root, ns['cbc'] + 'AcceptedIndicator')
+ accepted_indicator.text = 'true'
+ order_reference = etree.SubElement(
+ xml_root, ns['cac'] + 'OrderReference')
+ order_reference_id = etree.SubElement(
+ order_reference, ns['cbc'] + 'ID')
+ order_reference_id.text = self.client_order_ref or 'Missing'
+ self._ubl_add_supplier_party(
+ False, self.company_id, 'SellerSupplierParty', xml_root, ns,
+ version=version)
+ self._ubl_add_customer_party(
+ self.partner_id, False, 'BuyerCustomerParty', xml_root, ns,
+ version=version)
+ return xml_root
+
+ @api.multi
+ def generate_ubl_xml_string(self, doc_type, version='2.1'):
+ self.ensure_one()
+ assert doc_type in ('quotation', 'order'), 'wrong doc_type'
+ logger.debug('Starting to generate UBL XML %s file', doc_type)
+ lang = self.get_ubl_lang()
+ # The aim of injecting lang in context
+ # is to have the content of the XML in the partner's lang
+ # but the problem is that the error messages will also be in
+ # that lang. But the error messages should almost never
+ # happen except the first days of use, so it's probably
+ # not worth the additional code to handle the 2 langs
+ if doc_type == 'quotation':
+ xml_root = self.with_context(lang=lang).\
+ generate_quotation_ubl_xml_etree(version=version)
+ document = 'Quotation'
+ elif doc_type == 'order':
+ xml_root = self.with_context(lang=lang).\
+ generate_order_response_simple_ubl_xml_etree(version=version)
+ document = 'OrderResponseSimple'
+ xml_string = etree.tostring(
+ xml_root, pretty_print=True, encoding='UTF-8',
+ xml_declaration=True)
+ self._ubl_check_xml_schema(xml_string, document, version=version)
+ logger.debug(
+ '%s UBL XML file generated for sale order ID %d (state %s)',
+ doc_type, self.id, self.state)
+ logger.debug(xml_string)
+ return xml_string
+
+ @api.multi
+ def get_ubl_filename(self, doc_type, version='2.1'):
+ """This method is designed to be inherited"""
+ if doc_type == 'quotation':
+ return 'UBL-Quotation-%s.xml' % version
+ elif doc_type == 'order':
+ return 'UBL-OrderResponseSimple-%s.xml' % version
+
+ @api.multi
+ def get_ubl_version(self):
+ version = self._context.get('ubl_version') or '2.1'
+ return version
+
+ @api.multi
+ def get_ubl_lang(self):
+ return self.partner_id.lang or 'en_US'
+
+ @api.multi
+ def embed_ubl_xml_in_pdf(self, pdf_content=None, pdf_file=None):
+ self.ensure_one()
+ doc_type = False
+ if self.state in self.get_quotation_states():
+ doc_type = 'quotation'
+ elif self.state in self.get_order_states():
+ doc_type = 'order'
+ if doc_type:
+ version = self.get_ubl_version()
+ ubl_filename = self.get_ubl_filename(doc_type, version=version)
+ xml_string = self.generate_ubl_xml_string(
+ doc_type, version=version)
+ pdf_content = self.embed_xml_in_pdf(
+ xml_string, ubl_filename,
+ pdf_content=pdf_content, pdf_file=pdf_file)
+ return pdf_content
diff --git a/sale_order_ubl/readme/CONTRIBUTORS.rst b/sale_order_ubl/readme/CONTRIBUTORS.rst
new file mode 100644
index 0000000000..1befc12101
--- /dev/null
+++ b/sale_order_ubl/readme/CONTRIBUTORS.rst
@@ -0,0 +1,2 @@
+* Alexis de Lattre
+* Andrea Stirpe
diff --git a/sale_order_ubl/readme/DESCRIPTION.rst b/sale_order_ubl/readme/DESCRIPTION.rst
new file mode 100644
index 0000000000..1089fb73cd
--- /dev/null
+++ b/sale_order_ubl/readme/DESCRIPTION.rst
@@ -0,0 +1,10 @@
+This module adds support for UBL, the `Universal Business Language (UBL) `_ standard,
+on sale orders. The UBL 2.1 standard became the
+`ISO/IEC 19845 `_ standard
+in December 2015 (cf the `official announce `_).
+
+With this module, when you generate the sale order report:
+
+* on a draft/sent quotation, the PDF file will have an embedded XML *Quotation* file compliant with the UBL 2.1 or 2.0 standard.
+
+* on a confirmed sale order, the PDF file will have an embedded XML *Order Response Simple* file compliant with the UBL 2.1 or 2.0 standard.
diff --git a/sale_order_ubl/tests/__init__.py b/sale_order_ubl/tests/__init__.py
new file mode 100644
index 0000000000..895163754b
--- /dev/null
+++ b/sale_order_ubl/tests/__init__.py
@@ -0,0 +1,3 @@
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from . import test_ubl_generate
diff --git a/sale_order_ubl/tests/test_ubl_generate.py b/sale_order_ubl/tests/test_ubl_generate.py
new file mode 100644
index 0000000000..3f9b5a72ba
--- /dev/null
+++ b/sale_order_ubl/tests/test_ubl_generate.py
@@ -0,0 +1,32 @@
+# © 2016-2017 Akretion (Alexis de Lattre )
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo.tests.common import HttpCase
+
+
+class TestUblOrderImport(HttpCase):
+
+ def test_ubl_generate(self):
+ ro = self.env.ref('sale.action_report_saleorder')
+ soo = self.env['sale.order']
+ buo = self.env['base.ubl']
+ quotation_states = soo.get_quotation_states()
+ order_states = soo.get_order_states()
+ for i in range(8):
+ i += 1
+ order = self.env.ref('sale.sale_order_%d' % i)
+ for version in ['2.0', '2.1']:
+ pdf_file = ro.with_context(
+ ubl_version=version
+ ).render_qweb_pdf(order.ids)[0]
+ res = buo.get_xml_files_from_pdf(pdf_file)
+ if order.state in quotation_states:
+ filename = order.get_ubl_filename(
+ 'quotation', version=version)
+ self.assertTrue(filename in res)
+ elif order.state in order_states:
+ filename = order.get_ubl_filename(
+ 'order',
+ version=version
+ )
+ self.assertTrue(filename in res)