From 380f1c09ce2399761f4cf8ae74dafcfb05aa3deb Mon Sep 17 00:00:00 2001 From: Laurent Smet Date: Fri, 17 Aug 2018 15:47:13 +0200 Subject: [PATCH] [ADD] account_facturx: add import/export invoice using the Factur-x standard Factur-x is the e-invoicing standard in vigor in FR & DE and, by extension, it will probably become the standard in Europe. Thus this will be used as default e-invoicing standard by Odoo. The idea is simple: * when printing your customer invoice report (not in draft ofc), a Factur-x xml file is generated and embedded inside the PDF. * when importing supplier bills in the form of PDF files, we detect if any Factur-x xml file is attached and complete the data of the draft vendor bill accordingly. This is done during the message_post() function. /!\ To be compliant with the Factur-x standard, the PDF should be PDF/A-3b compliant. (See: http://fnfe-mpe.org/wp-content/uploads/2018/07/1.-FACTUR-X-V1.1-2018-07-24-FR.pdf chapter 6) However, wkhtmltopdf is not PDF/A compliant and then, the generated PDF report are not fully compliant with the Factur-x standard. Was task: 1859277 Was PR #25508 --- addons/account_facturx/__init__.py | 3 + addons/account_facturx/__manifest__.py | 13 + .../data/facturx_templates.xml | 235 ++++++++++++++++ addons/account_facturx/models/__init__.py | 4 + .../account_facturx/models/account_invoice.py | 262 ++++++++++++++++++ .../models/ir_actions_report.py | 33 +++ odoo/addons/base/models/ir_actions_report.py | 39 ++- 7 files changed, 575 insertions(+), 14 deletions(-) create mode 100644 addons/account_facturx/__init__.py create mode 100644 addons/account_facturx/__manifest__.py create mode 100644 addons/account_facturx/data/facturx_templates.xml create mode 100644 addons/account_facturx/models/__init__.py create mode 100644 addons/account_facturx/models/account_invoice.py create mode 100644 addons/account_facturx/models/ir_actions_report.py diff --git a/addons/account_facturx/__init__.py b/addons/account_facturx/__init__.py new file mode 100644 index 00000000000..21ff7c22f3c --- /dev/null +++ b/addons/account_facturx/__init__.py @@ -0,0 +1,3 @@ +# -*- encoding: utf-8 -*- + +from . import models diff --git a/addons/account_facturx/__manifest__.py b/addons/account_facturx/__manifest__.py new file mode 100644 index 00000000000..a615e3f9a45 --- /dev/null +++ b/addons/account_facturx/__manifest__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +{ + 'name' : 'Import Vendor Bills From XML', + 'version' : '1.0', + 'category': 'Accounting', + 'depends' : ['account'], + 'data': [ + 'data/facturx_templates.xml', + ], + 'installable': True, + 'application': False, + 'auto_install': True, +} diff --git a/addons/account_facturx/data/facturx_templates.xml b/addons/account_facturx/data/facturx_templates.xml new file mode 100644 index 00000000000..0537dff7f7d --- /dev/null +++ b/addons/account_facturx/data/facturx_templates.xml @@ -0,0 +1,235 @@ + + + + + + + + + + diff --git a/addons/account_facturx/models/__init__.py b/addons/account_facturx/models/__init__.py new file mode 100644 index 00000000000..3e3506a5453 --- /dev/null +++ b/addons/account_facturx/models/__init__.py @@ -0,0 +1,4 @@ +# -*- encoding: utf-8 -*- + +from . import account_invoice +from . import ir_actions_report diff --git a/addons/account_facturx/models/account_invoice.py b/addons/account_facturx/models/account_invoice.py new file mode 100644 index 00000000000..3d87880d69f --- /dev/null +++ b/addons/account_facturx/models/account_invoice.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models, fields, tools, _ +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, pycompat, float_repr +from odoo.tests.common import Form + +from datetime import datetime +from lxml import etree +from PyPDF2 import PdfFileReader +from collections import namedtuple + +import io +import base64 + + +DEFAULT_FACTURX_DATE_FORMAT = '%Y%m%d' + + +class AccountInvoice(models.Model): + _inherit = 'account.invoice' + _name = 'account.invoice' + + @api.multi + def _export_as_facturx_xml(self): + ''' Create the Factur-x xml file content. + :return: The XML content as str. + ''' + self.ensure_one() + + def format_date(dt): + # Format the date in the Factur-x standard. + dt = dt or datetime.now() + return dt.strftime(DEFAULT_FACTURX_DATE_FORMAT) + + def format_monetary(number, currency): + # Format the monetary values to avoid trailing decimals (e.g. 90.85000000000001). + return float_repr(number, currency.decimal_places) + + # Create file content. + template_values = { + 'record': self, + 'format_date': format_date, + 'format_monetary': format_monetary, + } + content = self.env.ref('account_facturx.account_invoice_facturx_export').render(template_values) + return b"" + content + + @api.multi + def _import_facturx_invoice(self, tree): + ''' Extract invoice values from the Factur-x xml tree passed as parameter. + + :param tree: The tree of the Factur-x xml file. + :return: A dictionary containing account.invoice values to create/update it. + ''' + amount_total_import = None + + # type must be present in the context to get the right behavior of the _default_journal method (account.invoice). + # journal_id must be present in the context to get the right behavior of the _default_account method (account.invoice.line). + self_ctx = self.with_context(type='in_invoice') + journal_id = self_ctx._default_journal().id + self_ctx = self_ctx.with_context(journal_id=journal_id) + + # self could be a single record (editing) or be empty (new). + with Form(self_ctx, view='account.invoice_supplier_form') as invoice_form: + + # Partner (first step to avoid warning 'Warning! You must first select a partner.'). + elements = tree.xpath('//ram:SellerTradeParty/ram:Name', namespaces=tree.nsmap) + partner_name = elements and elements[0].text + + elements = tree.xpath('//ram:SellerTradeParty/ram:SpecifiedTaxRegistration/ram:ID', namespaces=tree.nsmap) + partner = elements and self.env['res.partner'].search([('vat', '=', elements[0].text)], limit=1) + if not partner: + partner = elements and self.env['res.partner'].search([('name', 'ilike', partner_name)], limit=1) + if not partner: + elements = tree.xpath('//ram:SellerTradeParty//ram:URIID[@schemeID=\'SMTP\']', namespaces=tree.nsmap) + partner = elements and self.env['res.partner'].search([('email', '=', elements[0].text)], limit=1) + if partner: + invoice_form.partner_id = partner + + # Reference. + elements = tree.xpath('//rsm:ExchangedDocument/ram:ID', namespaces=tree.nsmap) + if elements: + invoice_form.reference = elements[0].text + + # Name. + elements = tree.xpath('//ram:BuyerOrderReferencedDocument/ram:IssuerAssignedID', namespaces=tree.nsmap) + if elements: + invoice_form.name = elements[0].text + + # Comment. + elements = tree.xpath('//ram:IncludedNote/ram:Content', namespaces=tree.nsmap) + if elements: + invoice_form.comment = elements[0].text + + # Refund type. + # There is two modes to handle refund in Factur-X: + # a) type_code == 380 for invoice, type_code == 381 for refund, all positive amounts. + # b) type_code == 380, negative amounts in case of refund. + # To handle both, we consider the 'a' mode and switch to 'b' if a negative amount is encountered. + elements = tree.xpath('//rsm:ExchangedDocument/ram:TypeCode', namespaces=tree.nsmap) + type_code = elements[0].text + refund_sign = 1 + + # Total amount. + elements = tree.xpath('//ram:GrandTotalAmount', namespaces=tree.nsmap) + if elements: + total_amount = float(elements[0].text) + + # Handle 'b' refund mode. + if total_amount < 0 and type_code == '380': + refund_sign = -1 + + # Currency. + if elements[0].attrib.get('currencyID'): + currency_str = elements[0].attrib['currencyID'] + currency = self.env.ref('base.%s' % currency_str.upper(), raise_if_not_found=False) + if currency != self.env.user.company_id.currency_id and currency.active: + invoice_form.currency_id = currency + + # Store xml total amount. + amount_total_import = total_amount * refund_sign + + # Date. + elements = tree.xpath('//rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString', namespaces=tree.nsmap) + if elements: + date_str = elements[0].text + date_obj = datetime.strptime(date_str, DEFAULT_FACTURX_DATE_FORMAT) + invoice_form.date_invoice = date_obj.strftime(DEFAULT_SERVER_DATE_FORMAT) + + # Due date. + elements = tree.xpath('//ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString', namespaces=tree.nsmap) + if elements: + date_str = elements[0].text + date_obj = datetime.strptime(date_str, DEFAULT_FACTURX_DATE_FORMAT) + invoice_form.date_due = date_obj.strftime(DEFAULT_SERVER_DATE_FORMAT) + + # Invoice lines. + elements = tree.xpath('//ram:IncludedSupplyChainTradeLineItem', namespaces=tree.nsmap) + if elements: + for element in elements: + with invoice_form.invoice_line_ids.new() as invoice_line_form: + + # Sequence. + line_elements = element.xpath('.//ram:AssociatedDocumentLineDocument/ram:LineID', namespaces=tree.nsmap) + if line_elements: + invoice_line_form.sequence = int(line_elements[0].text) + + # Product. + line_elements = element.xpath('.//ram:SpecifiedTradeProduct/ram:Name', namespaces=tree.nsmap) + if line_elements: + invoice_line_form.name = line_elements[0].text + line_elements = element.xpath('.//ram:SpecifiedTradeProduct/ram:SellerAssignedID', namespaces=tree.nsmap) + if line_elements and line_elements[0].text: + product = self.env['product.product'].search([('default_code', '=', line_elements[0].text)]) + if product: + invoice_line_form.product_id = product + + # Price Unit. + line_elements = element.xpath('.//ram:GrossPriceProductTradePrice/ram:ChargeAmount', namespaces=tree.nsmap) + if line_elements: + invoice_line_form.price_unit = float(line_elements[0].text) + else: + line_elements = element.xpath('.//ram:NetPriceProductTradePrice/ram:ChargeAmount', namespaces=tree.nsmap) + if line_elements: + invoice_line_form.price_unit = float(line_elements[0].text) + + # Quantity. + line_elements = element.xpath('.//ram:SpecifiedLineTradeDelivery/ram:BilledQuantity', namespaces=tree.nsmap) + if line_elements: + invoice_line_form.quantity = float(line_elements[0].text) * refund_sign + + # Discount. + line_elements = element.xpath('.//ram:AppliedTradeAllowanceCharge/ram:CalculationPercent', namespaces=tree.nsmap) + if line_elements: + invoice_line_form.discount = float(line_elements[0].text) + + # Taxes + line_elements = element.xpath('.//ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:RateApplicablePercent', namespaces=tree.nsmap) + invoice_line_form.invoice_line_tax_ids.clear() + for tax_element in line_elements: + percentage = float(tax_element.text) + + tax = self.env['account.tax'].search([ + ('company_id', '=', invoice_form.company_id.id), + ('amount_type', '=', 'percent'), + ('type_tax_use', '=', 'purchase'), + ('amount', '=', percentage), + ], limit=1) + + if tax: + invoice_line_form.invoice_line_tax_ids.add(tax) + elif amount_total_import: + # No lines in BASICWL. + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.name = invoice_form.comment or '/' + invoice_line_form.quantity = 1 + invoice_line_form.price_unit = amount_total_import + + # Refund. + invoice_form.type = 'in_refund' if refund_sign == -1 else 'in_invoice' + + return invoice_form.save() + + @api.multi + @api.returns('self', lambda value: value.id) + def message_post(self, **kwargs): + # OVERRIDE + # /!\ 'default_res_id' in self._context is used to don't process attachment when using a form view. + res = super(AccountInvoice, self).message_post(**kwargs) + + def _get_attachment_filename(attachment): + # Handle both _Attachment namedtuple in mail.thread or ir.attachment. + return hasattr(attachment, 'fname') and getattr(attachment, 'fname') or attachment.name + + def _get_attachment_content(attachment): + # Handle both _Attachment namedtuple in mail.thread or ir.attachment. + return hasattr(attachment, 'content') and getattr(attachment, 'content') or base64.b64decode(attachment.datas) + + if 'default_res_id' not in self._context and len(self) == 1 and self.state == 'draft' and self.type in ('in_invoice', 'in_refund'): + # Get attachments. + # - 'attachments' is a namedtuple defined in mail.thread looking like: + # _Attachment = namedtuple('Attachment', ('fname', 'content', 'info')) + # - 'attachment_ids' is a list of ir.attachment records ids. + attachments = kwargs.get('attachments', []) + if kwargs.get('attachment_ids'): + attachments += self.env['ir.attachment'].browse(kwargs['attachment_ids']) + + for attachment in attachments: + filename = _get_attachment_filename(attachment) + content = _get_attachment_content(attachment) + + # Check if the attachment is a pdf. + if not filename.endswith('.pdf'): + continue + + buffer = io.BytesIO(content) + try: + reader = PdfFileReader(buffer) + + # Search for Factur-x embedded file. + if reader.trailer['/Root'].get('/Names') and reader.trailer['/Root']['/Names'].get('/EmbeddedFiles'): + # N.B: embedded_files looks like: + # ['file.xml', {'/Type': '/Filespec', '/F': 'file.xml', '/EF': {'/F': IndirectObject(22, 0)}}] + embedded_files = reader.trailer['/Root']['/Names']['/EmbeddedFiles']['/Names'] + # '[::2]' because it's a list [fn_1, content_1, fn_2, content_2, ..., fn_n, content_2] + for filename_obj, content_obj in list(pycompat.izip(embedded_files, embedded_files[1:]))[::2]: + content = content_obj.getObject()['/EF']['/F'].getData() + + if filename_obj == 'factur-x.xml': + try: + tree = etree.fromstring(content) + except: + continue + + self._import_facturx_invoice(tree) + buffer.close() + return res + except: + # Malformed PDF. + pass + buffer.close() + return res diff --git a/addons/account_facturx/models/ir_actions_report.py b/addons/account_facturx/models/ir_actions_report.py new file mode 100644 index 00000000000..9e1d1f179a5 --- /dev/null +++ b/addons/account_facturx/models/ir_actions_report.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ + +from PyPDF2 import PdfFileWriter, PdfFileReader + +import io + + +class IrActionsReport(models.Model): + _inherit = 'ir.actions.report' + + @api.multi + def _post_pdf(self, save_in_attachment, pdf_content=None, res_ids=None): + # OVERRIDE + if self.model == 'account.invoice' and (res_ids or len(res_ids) == 1): + invoice = self.env['account.invoice'].browse(res_ids) + if invoice.type in ('out_invoice', 'out_refund') and invoice.state != 'draft': + xml_content = invoice._export_as_facturx_xml() + + # Add attachment. + reader_buffer = io.BytesIO(pdf_content) + reader = PdfFileReader(reader_buffer) + writer = PdfFileWriter() + writer.cloneReaderDocumentRoot(reader) + writer.addAttachment('factur-x.xml', xml_content) + buffer = io.BytesIO() + writer.write(buffer) + pdf_content = buffer.getvalue() + + reader_buffer.close() + buffer.close() + return super(IrActionsReport, self)._post_pdf(save_in_attachment, pdf_content=pdf_content, res_ids=res_ids) diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py index e2a7036f324..5e81443d2bb 100644 --- a/odoo/addons/base/models/ir_actions_report.py +++ b/odoo/addons/base/models/ir_actions_report.py @@ -162,7 +162,7 @@ class IrActionsReport(models.Model): :param record_id: The record that will own the attachment. :param pdf_content: The optional name content of the file to avoid reading both times. - :return: The newly generated attachment if no AccessError, else None. + :return: A modified buffer if the previous one has been modified, None otherwise. ''' attachment_name = safe_eval(self.attachment, {'object': record, 'time': time}) if not attachment_name: @@ -174,14 +174,13 @@ class IrActionsReport(models.Model): 'res_model': self.model, 'res_id': record.id, } - attachment = None try: - attachment = self.env['ir.attachment'].create(attachment_vals) + self.env['ir.attachment'].create(attachment_vals) except AccessError: _logger.info("Cannot save PDF report %r as attachment", attachment_vals['name']) else: _logger.info('The PDF document %s is now saved in the database', attachment_vals['name']) - return attachment + return buffer @api.model def get_wkhtmltopdf_state(self): @@ -521,7 +520,11 @@ class IrActionsReport(models.Model): if len(res_ids) == 1: # Only one record, so postprocess directly and append the whole pdf. if res_ids[0] in record_map and not res_ids[0] in save_in_attachment: - self.postprocess_pdf_report(record_map[res_ids[0]], pdf_content_stream) + new_stream = self.postprocess_pdf_report(record_map[res_ids[0]], pdf_content_stream) + # If the buffer has been modified, mark the old buffer to be closed as well. + if new_stream and new_stream != pdf_content_stream: + close_streams([pdf_content_stream]) + pdf_content_stream = new_stream streams.append(pdf_content_stream) else: # In case of multiple docs, we need to split the pdf according the records. @@ -542,7 +545,11 @@ class IrActionsReport(models.Model): stream = io.BytesIO() attachment_writer.write(stream) if res_ids[i] and res_ids[i] not in save_in_attachment: - self.postprocess_pdf_report(record_map[res_ids[i]], stream) + new_stream = self.postprocess_pdf_report(record_map[res_ids[i]], stream) + # If the buffer has been modified, mark the old buffer to be closed as well. + if new_stream and new_stream != stream: + close_streams([stream]) + stream = new_stream streams.append(stream) close_streams([pdf_content_stream]) else: @@ -557,14 +564,18 @@ class IrActionsReport(models.Model): streams.append(io.BytesIO(content)) # Build the final pdf. - writer = PdfFileWriter() - for stream in streams: - reader = PdfFileReader(stream) - writer.appendPagesFromReader(reader) - result_stream = io.BytesIO() - streams.append(result_stream) - writer.write(result_stream) - result = result_stream.getvalue() + # If only one stream left, no need to merge them (and then, preserve embedded files). + if len(streams) == 1: + result = streams[0].getvalue() + else: + writer = PdfFileWriter() + for stream in streams: + reader = PdfFileReader(stream) + writer.appendPagesFromReader(reader) + result_stream = io.BytesIO() + streams.append(result_stream) + writer.write(result_stream) + result = result_stream.getvalue() # We have to close the streams after PdfFileWriter's call to write() close_streams(streams)