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)