summaryrefslogtreecommitdiff
path: root/addons/account_edi/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/account_edi/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/account_edi/models')
-rw-r--r--addons/account_edi/models/__init__.py10
-rw-r--r--addons/account_edi/models/account_edi_document.py252
-rw-r--r--addons/account_edi/models/account_edi_format.py552
-rw-r--r--addons/account_edi/models/account_journal.py53
-rw-r--r--addons/account_edi/models/account_move.py290
-rw-r--r--addons/account_edi/models/account_payment.py11
-rw-r--r--addons/account_edi/models/ir_actions_report.py16
-rw-r--r--addons/account_edi/models/ir_attachment.py15
-rw-r--r--addons/account_edi/models/mail_template.py35
9 files changed, 1234 insertions, 0 deletions
diff --git a/addons/account_edi/models/__init__.py b/addons/account_edi/models/__init__.py
new file mode 100644
index 00000000..1d924dbd
--- /dev/null
+++ b/addons/account_edi/models/__init__.py
@@ -0,0 +1,10 @@
+# -*- encoding: utf-8 -*-
+
+from . import account_move
+from . import account_journal
+from . import account_edi_format
+from . import account_edi_document
+from . import account_payment
+from . import ir_actions_report
+from . import mail_template
+from . import ir_attachment
diff --git a/addons/account_edi/models/account_edi_document.py b/addons/account_edi/models/account_edi_document.py
new file mode 100644
index 00000000..afd58a07
--- /dev/null
+++ b/addons/account_edi/models/account_edi_document.py
@@ -0,0 +1,252 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields, api
+from odoo.addons.account_edi_extended.models.account_edi_document import DEFAULT_BLOCKING_LEVEL
+from psycopg2 import OperationalError
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+class AccountEdiDocument(models.Model):
+ _name = 'account.edi.document'
+ _description = 'Electronic Document for an account.move'
+
+ # == Stored fields ==
+ move_id = fields.Many2one('account.move', required=True, ondelete='cascade')
+ edi_format_id = fields.Many2one('account.edi.format', required=True)
+ attachment_id = fields.Many2one('ir.attachment', help='The file generated by edi_format_id when the invoice is posted (and this document is processed).')
+ state = fields.Selection([('to_send', 'To Send'), ('sent', 'Sent'), ('to_cancel', 'To Cancel'), ('cancelled', 'Cancelled')])
+ error = fields.Html(help='The text of the last error that happened during Electronic Invoice operation.')
+
+ # == Not stored fields ==
+ name = fields.Char(related='attachment_id.name')
+ edi_format_name = fields.Char(string='Format Name', related='edi_format_id.name')
+
+ _sql_constraints = [
+ (
+ 'unique_edi_document_by_move_by_format',
+ 'UNIQUE(edi_format_id, move_id)',
+ 'Only one edi document by move by format',
+ ),
+ ]
+
+ def write(self, vals):
+ ''' If account_edi_extended is not installed, a default behaviour is used instead.
+ '''
+ if 'blocking_level' in vals and 'blocking_level' not in self.env['account.edi.document']._fields:
+ vals.pop('blocking_level')
+
+ return super().write(vals)
+
+ def _prepare_jobs(self):
+ """Creates a list of jobs to be performed by '_process_job' for the documents in self.
+ Each document represent a job, BUT if multiple documents have the same state, edi_format_id,
+ doc_type (invoice or payment) and company_id AND the edi_format_id supports batching, they are grouped
+ into a single job.
+
+ :returns: A list of tuples (documents, doc_type)
+ * documents: The documents related to this job. If edi_format_id does not support batch, length is one
+ * doc_type: Are the moves of this job invoice or payments ?
+ """
+
+ # Classify jobs by (edi_format, edi_doc.state, doc_type, move.company_id, custom_key)
+ to_process = {}
+ if 'blocking_level' in self.env['account.edi.document']._fields:
+ documents = self.filtered(lambda d: d.state in ('to_send', 'to_cancel') and d.blocking_level != 'error')
+ else:
+ documents = self.filtered(lambda d: d.state in ('to_send', 'to_cancel'))
+ for edi_doc in documents:
+ move = edi_doc.move_id
+ edi_format = edi_doc.edi_format_id
+ if move.is_invoice(include_receipts=True):
+ doc_type = 'invoice'
+ elif move.payment_id or move.statement_line_id:
+ doc_type = 'payment'
+ else:
+ continue
+
+ custom_key = edi_format._get_batch_key(edi_doc.move_id, edi_doc.state)
+ key = (edi_format, edi_doc.state, doc_type, move.company_id, custom_key)
+ to_process.setdefault(key, self.env['account.edi.document'])
+ to_process[key] |= edi_doc
+
+ # Order payments/invoice and create batches.
+ invoices = []
+ payments = []
+ for key, documents in to_process.items():
+ edi_format, state, doc_type, company_id, custom_key = key
+ target = invoices if doc_type == 'invoice' else payments
+ batch = self.env['account.edi.document']
+ for doc in documents:
+ if edi_format._support_batching(move=doc.move_id, state=state, company=company_id):
+ batch |= doc
+ else:
+ target.append((doc, doc_type))
+ if batch:
+ target.append((batch, doc_type))
+ return invoices + payments
+
+ @api.model
+ def _convert_to_old_jobs_format(self, jobs):
+ """ See '_prepare_jobs' :
+ Old format : ((edi_format, state, doc_type, company_id), documents)
+ Since edi_format, state and company_id can be deduced from documents, this is redundant and more prone to unexpected behaviours.
+ New format : (doc_type, documents).
+
+ However, for backward compatibility of 'process_jobs', we need a way to convert back to the old format.
+ """
+ return [(
+ (documents.edi_format_id, documents[0].state, doc_type, documents.move_id.company_id),
+ documents
+ ) for documents, doc_type in jobs]
+
+ @api.model
+ def _process_jobs(self, to_process):
+ """ Deprecated, use _process_job instead.
+
+ :param to_process: A list of tuples (key, documents)
+ * key: A tuple (edi_format_id, state, doc_type, company_id)
+ ** edi_format_id: The format to perform the operation with
+ ** state: The state of the documents of this job
+ ** doc_type: Are the moves of this job invoice or payments ?
+ ** company_id: The company the moves belong to
+ * documents: The documents related to this job. If edi_format_id does not support batch, length is one
+ """
+ for key, documents in to_process:
+ edi_format, state, doc_type, company_id = key
+ self._process_job(documents, doc_type)
+
+ @api.model
+ def _process_job(self, documents, doc_type):
+ """Post or cancel move_id (invoice or payment) by calling the related methods on edi_format_id.
+ Invoices are processed before payments.
+
+ :param documents: The documents related to this job. If edi_format_id does not support batch, length is one
+ :param doc_type: Are the moves of this job invoice or payments ?
+ """
+ def _postprocess_post_edi_results(documents, edi_result):
+ attachments_to_unlink = self.env['ir.attachment']
+ for document in documents:
+ move = document.move_id
+ move_result = edi_result.get(move, {})
+ if move_result.get('attachment'):
+ old_attachment = document.attachment_id
+ values = {
+ 'attachment_id': move_result['attachment'].id,
+ 'error': move_result.get('error', False),
+ 'blocking_level': move_result.get('blocking_level', DEFAULT_BLOCKING_LEVEL) if 'error' in move_result else False,
+ }
+ if not values.get('error'):
+ values.update({'state': 'sent'})
+ document.write(values)
+ if not old_attachment.res_model or not old_attachment.res_id:
+ attachments_to_unlink |= old_attachment
+ else:
+ document.write({
+ 'error': move_result.get('error', False),
+ 'blocking_level': move_result.get('blocking_level', DEFAULT_BLOCKING_LEVEL) if 'error' in move_result else False,
+ })
+
+ # Attachments that are not explicitly linked to a business model could be removed because they are not
+ # supposed to have any traceability from the user.
+ attachments_to_unlink.unlink()
+
+ def _postprocess_cancel_edi_results(documents, edi_result):
+ invoice_ids_to_cancel = set() # Avoid duplicates
+ attachments_to_unlink = self.env['ir.attachment']
+ for document in documents:
+ move = document.move_id
+ move_result = edi_result.get(move, {})
+ if move_result.get('success') is True:
+ old_attachment = document.attachment_id
+ document.write({
+ 'state': 'cancelled',
+ 'error': False,
+ 'attachment_id': False,
+ 'blocking_level': False,
+ })
+
+ if move.is_invoice(include_receipts=True) and move.state == 'posted':
+ # The user requested a cancellation of the EDI and it has been approved. Then, the invoice
+ # can be safely cancelled.
+ invoice_ids_to_cancel.add(move.id)
+
+ if not old_attachment.res_model or not old_attachment.res_id:
+ attachments_to_unlink |= old_attachment
+
+ elif not move_result.get('success'):
+ document.write({
+ 'error': move_result.get('error', False),
+ 'blocking_level': move_result.get('blocking_level', DEFAULT_BLOCKING_LEVEL) if move_result.get('error') else False,
+ })
+
+ if invoice_ids_to_cancel:
+ invoices = self.env['account.move'].browse(list(invoice_ids_to_cancel))
+ invoices.button_draft()
+ invoices.button_cancel()
+
+ # Attachments that are not explicitly linked to a business model could be removed because they are not
+ # supposed to have any traceability from the user.
+ attachments_to_unlink.unlink()
+
+ test_mode = self._context.get('edi_test_mode', False)
+
+ documents.edi_format_id.ensure_one() # All account.edi.document of a job should have the same edi_format_id
+ documents.move_id.company_id.ensure_one() # All account.edi.document of a job should be from the same company
+ if len(set(doc.state for doc in documents)) != 1:
+ raise ValueError('All account.edi.document of a job should have the same state')
+
+ edi_format = documents.edi_format_id
+ state = documents[0].state
+ if doc_type == 'invoice':
+ if state == 'to_send':
+ edi_result = edi_format._post_invoice_edi(documents.move_id, test_mode=test_mode)
+ _postprocess_post_edi_results(documents, edi_result)
+ elif state == 'to_cancel':
+ edi_result = edi_format._cancel_invoice_edi(documents.move_id, test_mode=test_mode)
+ _postprocess_cancel_edi_results(documents, edi_result)
+
+ elif doc_type == 'payment':
+ if state == 'to_send':
+ edi_result = edi_format._post_payment_edi(documents.move_id, test_mode=test_mode)
+ _postprocess_post_edi_results(documents, edi_result)
+ elif state == 'to_cancel':
+ edi_result = edi_format._cancel_payment_edi(documents.move_id, test_mode=test_mode)
+ _postprocess_cancel_edi_results(documents, edi_result)
+
+ def _process_documents_no_web_services(self):
+ """ Post and cancel all the documents that don't need a web service.
+ """
+ jobs = self.filtered(lambda d: not d.edi_format_id._needs_web_services())._prepare_jobs()
+ self._process_jobs(self._convert_to_old_jobs_format(jobs))
+
+ def _process_documents_web_services(self, job_count=None, with_commit=True):
+ """ Post and cancel all the documents that need a web service. This is called by CRON.
+
+ :param job_count: Limit to the number of jobs to process among the ones that are available for treatment.
+ """
+ jobs = self.filtered(lambda d: d.edi_format_id._needs_web_services())._prepare_jobs()
+ jobs = jobs[0:job_count or len(jobs)]
+ for documents, doc_type in jobs:
+ move_to_lock = documents.move_id
+ attachments_potential_unlink = documents.attachment_id.filtered(lambda a: not a.res_model and not a.res_id)
+ try:
+ with self.env.cr.savepoint():
+ self._cr.execute('SELECT * FROM account_edi_document WHERE id IN %s FOR UPDATE NOWAIT', [tuple(documents.ids)])
+ self._cr.execute('SELECT * FROM account_move WHERE id IN %s FOR UPDATE NOWAIT', [tuple(move_to_lock.ids)])
+
+ # Locks the attachments that might be unlinked
+ if attachments_potential_unlink:
+ self._cr.execute('SELECT * FROM ir_attachment WHERE id IN %s FOR UPDATE NOWAIT', [tuple(attachments_potential_unlink.ids)])
+
+ self._process_job(documents, doc_type)
+ except OperationalError as e:
+ if e.pgcode == '55P03':
+ _logger.debug('Another transaction already locked documents rows. Cannot process documents.')
+ else:
+ raise e
+ else:
+ if with_commit and len(jobs) > 1:
+ self.env.cr.commit()
diff --git a/addons/account_edi/models/account_edi_format.py b/addons/account_edi/models/account_edi_format.py
new file mode 100644
index 00000000..1dce2c58
--- /dev/null
+++ b/addons/account_edi/models/account_edi_format.py
@@ -0,0 +1,552 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields, api
+from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter
+from odoo.osv import expression
+from odoo.tools import html_escape
+
+from lxml import etree
+import base64
+import io
+import logging
+import pathlib
+
+_logger = logging.getLogger(__name__)
+
+
+class AccountEdiFormat(models.Model):
+ _name = 'account.edi.format'
+ _description = 'EDI format'
+
+ name = fields.Char()
+ code = fields.Char(required=True)
+
+ _sql_constraints = [
+ ('unique_code', 'unique (code)', 'This code already exists')
+ ]
+
+
+ ####################################################
+ # Low-level methods
+ ####################################################
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ edi_formats = super().create(vals_list)
+
+ # activate by default on journal
+ journals = self.env['account.journal'].search([])
+ for journal in journals:
+ for edi_format in edi_formats:
+ if edi_format._is_compatible_with_journal(journal):
+ journal.edi_format_ids += edi_format
+
+ # activate cron
+ if any(edi_format._needs_web_services() for edi_format in edi_formats):
+ self.env.ref('account_edi.ir_cron_edi_network').active = True
+
+ return edi_formats
+
+ ####################################################
+ # Export method to override based on EDI Format
+ ####################################################
+
+ def _is_required_for_invoice(self, invoice):
+ """ Indicate if this EDI must be generated for the invoice passed as parameter.
+
+ :param invoice: An account.move having the invoice type.
+ :returns: True if the EDI must be generated, False otherwise.
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+ return True
+
+ def _is_required_for_payment(self, payment):
+ """ Indicate if this EDI must be generated for the payment passed as parameter.
+
+ :param payment: An account.move linked to either an account.payment, either an account.bank.statement.line.
+ :returns: True if the EDI must be generated, False otherwise.
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+ return False
+
+ def _needs_web_services(self):
+ """ Indicate if the EDI must be generated asynchronously through to some web services.
+
+ :return: True if such a web service is available, False otherwise.
+ """
+ self.ensure_one()
+ return False
+
+ def _is_compatible_with_journal(self, journal):
+ """ Indicate if the EDI format should appear on the journal passed as parameter to be selected by the user.
+ If True, this EDI format will be selected by default on the journal.
+
+ :param journal: The journal.
+ :returns: True if this format can be enabled by default on the journal, False otherwise.
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+ return journal.type == 'sale'
+
+ def _is_embedding_to_invoice_pdf_needed(self):
+ """ Indicate if the EDI must be embedded inside the PDF report.
+
+ :returns: True if the documents need to be embedded, False otherwise.
+ """
+ # TO OVERRIDE
+ return False
+
+ def _get_embedding_to_invoice_pdf_values(self, invoice):
+ """ Get the values to embed to pdf.
+
+ :returns: A dictionary {'name': name, 'datas': datas} or False if there are no values to embed.
+ * name: The name of the file.
+ * datas: The bytes ot the file.
+ """
+ self.ensure_one()
+ attachment = invoice._get_edi_attachment(self)
+ if not attachment or not self._is_embedding_to_invoice_pdf_needed():
+ return False
+ datas = base64.b64decode(attachment.with_context(bin_size=False).datas)
+ return {'name': attachment.name, 'datas': datas}
+
+ def _support_batching(self, move=None, state=None, company=None):
+ """ Indicate if we can send multiple documents in the same time to the web services.
+ If True, the _post_%s_edi methods will get multiple documents in the same time.
+ Otherwise, these methods will be called with only one record at a time.
+
+ :returns: True if batching is supported, False otherwise.
+ """
+ # TO OVERRIDE
+ return False
+
+ def _get_batch_key(self, move, state):
+ """ Returns a tuple that will be used as key to partitionnate the invoices/payments when creating batches
+ with multiple invoices/payments.
+ The type of move (invoice or payment), its company_id, its edi state and the edi_format are used by default, if
+ no further partition is needed for this format, this method should return ().
+
+ :returns: The key to be used when partitionning the batches.
+ """
+ move.ensure_one()
+ return ()
+
+ def _check_move_configuration(self, move):
+ """ Checks the move and relevant records for potential error (missing data, etc).
+
+ :param invoice: The move to check.
+ :returns: A list of error messages.
+ """
+ # TO OVERRIDE
+ return []
+
+ def _post_invoice_edi(self, invoices, test_mode=False):
+ """ Create the file content representing the invoice (and calls web services if necessary).
+
+ :param invoices: A list of invoices to post.
+ :param test_mode: A flag indicating the EDI should only simulate the EDI without sending data.
+ :returns: A dictionary with the invoice as key and as value, another dictionary:
+ * attachment: The attachment representing the invoice in this edi_format if the edi was successfully posted.
+ * error: An error if the edi was not successfully posted.
+ * blocking_level: (optional, requires account_edi_extended) How bad is the error (how should the edi flow be blocked ?)
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+ return {}
+
+ def _cancel_invoice_edi(self, invoices, test_mode=False):
+ """Calls the web services to cancel the invoice of this document.
+
+ :param invoices: A list of invoices to cancel.
+ :param test_mode: A flag indicating the EDI should only simulate the EDI without sending data.
+ :returns: A dictionary with the invoice as key and as value, another dictionary:
+ * success: True if the invoice was successfully cancelled.
+ * error: An error if the edi was not successfully cancelled.
+ * blocking_level: (optional, requires account_edi_extended) How bad is the error (how should the edi flow be blocked ?)
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+ return {invoice: {'success': True} for invoice in invoices} # By default, cancel succeeds doing nothing.
+
+ def _post_payment_edi(self, payments, test_mode=False):
+ """ Create the file content representing the payment (and calls web services if necessary).
+
+ :param payments: The payments to post.
+ :param test_mode: A flag indicating the EDI should only simulate the EDI without sending data.
+ :returns: A dictionary with the payment as key and as value, another dictionary:
+ * attachment: The attachment representing the payment in this edi_format if the edi was successfully posted.
+ * error: An error if the edi was not successfully posted.
+ * blocking_level: (optional, requires account_edi_extended) How bad is the error (how should the edi flow be blocked ?)
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+ return {}
+
+ def _cancel_payment_edi(self, payments, test_mode=False):
+ """Calls the web services to cancel the payment of this document.
+
+ :param payments: A list of payments to cancel.
+ :param test_mode: A flag indicating the EDI should only simulate the EDI without sending data.
+ :returns: A dictionary with the payment as key and as value, another dictionary:
+ * success: True if the payment was successfully cancelled.
+ * error: An error if the edi was not successfully cancelled.
+ * blocking_level: (optional, requires account_edi_extended) How bad is the error (how should the edi flow be blocked ?)
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+ return {payment: {'success': True} for payment in payments} # By default, cancel succeeds doing nothing.
+
+ ####################################################
+ # Import methods to override based on EDI Format
+ ####################################################
+
+ def _create_invoice_from_xml_tree(self, filename, tree):
+ """ Create a new invoice with the data inside the xml.
+
+ :param filename: The name of the xml.
+ :param tree: The tree of the xml to import.
+ :returns: The created invoice.
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+ return self.env['account.move']
+
+ def _update_invoice_from_xml_tree(self, filename, tree, invoice):
+ """ Update an existing invoice with the data inside the xml.
+
+ :param filename: The name of the xml.
+ :param tree: The tree of the xml to import.
+ :param invoice: The invoice to update.
+ :returns: The updated invoice.
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+ return self.env['account.move']
+
+ def _create_invoice_from_pdf_reader(self, filename, reader):
+ """ Create a new invoice with the data inside a pdf.
+
+ :param filename: The name of the pdf.
+ :param reader: The OdooPdfFileReader of the pdf to import.
+ :returns: The created invoice.
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+
+ return self.env['account.move']
+
+ def _update_invoice_from_pdf_reader(self, filename, reader, invoice):
+ """ Update an existing invoice with the data inside the pdf.
+
+ :param filename: The name of the pdf.
+ :param reader: The OdooPdfFileReader of the pdf to import.
+ :param invoice: The invoice to update.
+ :returns: The updated invoice.
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+ return self.env['account.move']
+
+ def _create_invoice_from_binary(self, filename, content, extension):
+ """ Create a new invoice with the data inside a binary file.
+
+ :param filename: The name of the file.
+ :param content: The content of the binary file.
+ :param extension: The extensions as a string.
+ :returns: The created invoice.
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+ return self.env['account.move']
+
+ def _update_invoice_from_binary(self, filename, content, extension, invoice):
+ """ Update an existing invoice with the data inside a binary file.
+
+ :param filename: The name of the file.
+ :param content: The content of the binary file.
+ :param extension: The extensions as a string.
+ :param invoice: The invoice to update.
+ :returns: The updated invoice.
+ """
+ # TO OVERRIDE
+ self.ensure_one()
+ return self.env['account.move']
+
+ ####################################################
+ # Export Internal methods (not meant to be overridden)
+ ####################################################
+
+ def _embed_edis_to_pdf(self, pdf_content, invoice):
+ """ Create the EDI document of the invoice and embed it in the pdf_content.
+
+ :param pdf_content: the bytes representing the pdf to add the EDIs to.
+ :param invoice: the invoice to generate the EDI from.
+ :returns: the same pdf_content with the EDI of the invoice embed in it.
+ """
+ attachments = []
+ for edi_format in self.filtered(lambda edi_format: edi_format._is_embedding_to_invoice_pdf_needed()):
+ attach = edi_format._get_embedding_to_invoice_pdf_values(invoice)
+ if attach:
+ attachments.append(attach)
+
+ if attachments:
+ # Add the attachments to the pdf file
+ reader_buffer = io.BytesIO(pdf_content)
+ reader = OdooPdfFileReader(reader_buffer, strict=False)
+ writer = OdooPdfFileWriter()
+ writer.cloneReaderDocumentRoot(reader)
+ for vals in attachments:
+ writer.addAttachment(vals['name'], vals['datas'])
+ buffer = io.BytesIO()
+ writer.write(buffer)
+ pdf_content = buffer.getvalue()
+ reader_buffer.close()
+ buffer.close()
+ return pdf_content
+
+ ####################################################
+ # Import Internal methods (not meant to be overridden)
+ ####################################################
+
+ def _decode_xml(self, filename, content):
+ """Decodes an xml into a list of one dictionary representing an attachment.
+
+ :param filename: The name of the xml.
+ :param content: The bytes representing the xml.
+ :returns: A list with a dictionary.
+ * filename: The name of the attachment.
+ * content: The content of the attachment.
+ * type: The type of the attachment.
+ * xml_tree: The tree of the xml if type is xml.
+ """
+ to_process = []
+ try:
+ xml_tree = etree.fromstring(content)
+ except Exception as e:
+ _logger.exception("Error when converting the xml content to etree: %s" % e)
+ return to_process
+ if len(xml_tree):
+ to_process.append({
+ 'filename': filename,
+ 'content': content,
+ 'type': 'xml',
+ 'xml_tree': xml_tree,
+ })
+ return to_process
+
+ def _decode_pdf(self, filename, content):
+ """Decodes a pdf and unwrap sub-attachment into a list of dictionary each representing an attachment.
+
+ :param filename: The name of the pdf.
+ :param content: The bytes representing the pdf.
+ :returns: A list of dictionary for each attachment.
+ * filename: The name of the attachment.
+ * content: The content of the attachment.
+ * type: The type of the attachment.
+ * xml_tree: The tree of the xml if type is xml.
+ * pdf_reader: The pdf_reader if type is pdf.
+ """
+ to_process = []
+ try:
+ buffer = io.BytesIO(content)
+ pdf_reader = OdooPdfFileReader(buffer, strict=False)
+ except Exception as e:
+ # Malformed pdf
+ _logger.exception("Error when reading the pdf: %s" % e)
+ return to_process
+
+ # Process embedded files.
+ try:
+ for xml_name, content in pdf_reader.getAttachments():
+ to_process.extend(self._decode_xml(xml_name, content))
+ except NotImplementedError as e:
+ _logger.warning("Unable to access the attachments of %s. Tried to decrypt it, but %s." % (filename, e))
+
+ # Process the pdf itself.
+ to_process.append({
+ 'filename': filename,
+ 'content': content,
+ 'type': 'pdf',
+ 'pdf_reader': pdf_reader,
+ })
+
+ return to_process
+
+ def _decode_binary(self, filename, content):
+ """Decodes any file into a list of one dictionary representing an attachment.
+ This is a fallback for all files that are not decoded by other methods.
+
+ :param filename: The name of the file.
+ :param content: The bytes representing the file.
+ :returns: A list with a dictionary.
+ * filename: The name of the attachment.
+ * content: The content of the attachment.
+ * type: The type of the attachment.
+ """
+ return [{
+ 'filename': filename,
+ 'extension': ''.join(pathlib.Path(filename).suffixes),
+ 'content': content,
+ 'type': 'binary',
+ }]
+
+ def _decode_attachment(self, attachment):
+ """Decodes an ir.attachment and unwrap sub-attachment into a list of dictionary each representing an attachment.
+
+ :param attachment: An ir.attachment record.
+ :returns: A list of dictionary for each attachment.
+ * filename: The name of the attachment.
+ * content: The content of the attachment.
+ * type: The type of the attachment.
+ * xml_tree: The tree of the xml if type is xml.
+ * pdf_reader: The pdf_reader if type is pdf.
+ """
+ content = base64.b64decode(attachment.with_context(bin_size=False).datas)
+ to_process = []
+
+ if 'pdf' in attachment.mimetype:
+ to_process.extend(self._decode_pdf(attachment.name, content))
+ elif 'xml' in attachment.mimetype:
+ to_process.extend(self._decode_xml(attachment.name, content))
+ else:
+ to_process.extend(self._decode_binary(attachment.name, content))
+
+ return to_process
+
+ def _create_invoice_from_attachment(self, attachment):
+ """Decodes an ir.attachment to create an invoice.
+
+ :param attachment: An ir.attachment record.
+ :returns: The invoice where to import data.
+ """
+ for file_data in self._decode_attachment(attachment):
+ for edi_format in self:
+ res = False
+ try:
+ if file_data['type'] == 'xml':
+ res = edi_format._create_invoice_from_xml_tree(file_data['filename'], file_data['xml_tree'])
+ elif file_data['type'] == 'pdf':
+ res = edi_format._create_invoice_from_pdf_reader(file_data['filename'], file_data['pdf_reader'])
+ file_data['pdf_reader'].stream.close()
+ else:
+ res = edi_format._create_invoice_from_binary(file_data['filename'], file_data['content'], file_data['extension'])
+ except Exception as e:
+ _logger.exception("Error importing attachment \"%s\" as invoice with format \"%s\"", file_data['filename'], edi_format.name, str(e))
+ if res:
+ if 'extract_state' in res:
+ # Bypass the OCR to prevent overwriting data when an EDI was succesfully imported.
+ # TODO : remove when we integrate the OCR to the EDI flow.
+ res.write({'extract_state': 'done'})
+ return res
+ return self.env['account.move']
+
+ def _update_invoice_from_attachment(self, attachment, invoice):
+ """Decodes an ir.attachment to update an invoice.
+
+ :param attachment: An ir.attachment record.
+ :returns: The invoice where to import data.
+ """
+ for file_data in self._decode_attachment(attachment):
+ for edi_format in self:
+ res = False
+ try:
+ if file_data['type'] == 'xml':
+ res = edi_format._update_invoice_from_xml_tree(file_data['filename'], file_data['xml_tree'], invoice)
+ elif file_data['type'] == 'pdf':
+ res = edi_format._update_invoice_from_pdf_reader(file_data['filename'], file_data['pdf_reader'], invoice)
+ file_data['pdf_reader'].stream.close()
+ else: # file_data['type'] == 'binary'
+ res = edi_format._update_invoice_from_binary(file_data['filename'], file_data['content'], file_data['extension'], invoice)
+ except Exception as e:
+ _logger.exception("Error importing attachment \"%s\" as invoice with format \"%s\"", file_data['filename'], edi_format.name, str(e))
+ if res:
+ if 'extract_state' in res:
+ # Bypass the OCR to prevent overwriting data when an EDI was succesfully imported.
+ # TODO : remove when we integrate the OCR to the EDI flow.
+ res.write({'extract_state': 'done'})
+ return res
+ return self.env['account.move']
+
+ ####################################################
+ # Import helpers
+ ####################################################
+
+ def _find_value(self, xpath, xml_element, namespaces=None):
+ element = xml_element.xpath(xpath, namespaces=namespaces)
+ return element[0].text if element else None
+
+ def _retrieve_partner(self, name=None, phone=None, mail=None, vat=None):
+ '''Search all partners and find one that matches one of the parameters.
+
+ :param name: The name of the partner.
+ :param phone: The phone or mobile of the partner.
+ :param mail: The mail of the partner.
+ :param vat: The vat number of the partner.
+ :returns: A partner or an empty recordset if not found.
+ '''
+ domains = []
+ for value, domain in (
+ (name, [('name', 'ilike', name)]),
+ (phone, expression.OR([[('phone', '=', phone)], [('mobile', '=', phone)]])),
+ (mail, [('email', '=', mail)]),
+ (vat, [('vat', 'like', vat)]),
+ ):
+ if value is not None:
+ domains.append(domain)
+
+ domain = expression.OR(domains)
+ return self.env['res.partner'].search(domain, limit=1)
+
+ def _retrieve_product(self, name=None, default_code=None, barcode=None):
+ '''Search all products and find one that matches one of the parameters.
+
+ :param name: The name of the product.
+ :param default_code: The default_code of the product.
+ :param barcode: The barcode of the product.
+ :returns: A product or an empty recordset if not found.
+ '''
+ domains = []
+ for value, domain in (
+ (name, ('name', 'ilike', name)),
+ (default_code, ('default_code', '=', default_code)),
+ (barcode, ('barcode', '=', barcode)),
+ ):
+ if value is not None:
+ domains.append([domain])
+
+ domain = expression.OR(domains)
+ return self.env['product.product'].search(domain, limit=1)
+
+ def _retrieve_tax(self, amount, type_tax_use):
+ '''Search all taxes and find one that matches all of the parameters.
+
+ :param amount: The amount of the tax.
+ :param type_tax_use: The type of the tax.
+ :returns: A tax or an empty recordset if not found.
+ '''
+ domains = [
+ [('amount', '=', float(amount))],
+ [('type_tax_use', '=', type_tax_use)]
+ ]
+
+ return self.env['account.tax'].search(expression.AND(domains), order='sequence ASC', limit=1)
+
+ def _retrieve_currency(self, code):
+ '''Search all currencies and find one that matches the code.
+
+ :param code: The code of the currency.
+ :returns: A currency or an empty recordset if not found.
+ '''
+ return self.env['res.currency'].search([('name', '=', code.upper())], limit=1)
+
+ ####################################################
+ # Other helpers
+ ####################################################
+
+ @api.model
+ def _format_error_message(self, error_title, errors):
+ bullet_list_msg = ''.join('<li>%s</li>' % html_escape(msg) for msg in errors)
+ return '%s<ul>%s</ul>' % (error_title, bullet_list_msg)
diff --git a/addons/account_edi/models/account_journal.py b/addons/account_edi/models/account_journal.py
new file mode 100644
index 00000000..a40a90b2
--- /dev/null
+++ b/addons/account_edi/models/account_journal.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models, fields, _
+from odoo.exceptions import UserError
+
+
+class AccountJournal(models.Model):
+ _inherit = 'account.journal'
+
+ edi_format_ids = fields.Many2many(comodel_name='account.edi.format',
+ string='Electronic invoicing',
+ help='Send XML/EDI invoices',
+ domain="[('id', 'in', compatible_edi_ids)]",
+ compute='_compute_edi_format_ids',
+ readonly=False, store=True)
+
+ compatible_edi_ids = fields.Many2many(comodel_name='account.edi.format',
+ compute='_compute_compatible_edi_ids',
+ help='EDI format that support moves in this journal')
+
+ def write(self, vals):
+ # OVERRIDE
+ # Don't allow the user to deactivate an edi format having at least one document to be processed.
+ if vals.get('edi_format_ids'):
+ old_edi_format_ids = self.edi_format_ids
+ res = super().write(vals)
+ diff_edi_format_ids = old_edi_format_ids - self.edi_format_ids
+ documents = self.env['account.edi.document'].search([
+ ('move_id.journal_id', 'in', self.ids),
+ ('edi_format_id', 'in', diff_edi_format_ids.ids),
+ ('state', 'in', ('to_cancel', 'to_send')),
+ ])
+ if documents:
+ raise UserError(_('Cannot deactivate (%s) on this journal because not all documents are synchronized', ', '.join(documents.edi_format_id.mapped('display_name'))))
+ return res
+ else:
+ return super().write(vals)
+
+ @api.depends('type', 'company_id', 'company_id.country_id')
+ def _compute_compatible_edi_ids(self):
+ edi_formats = self.env['account.edi.format'].search([])
+
+ for journal in self:
+ compatible_edis = edi_formats.filtered(lambda e: e._is_compatible_with_journal(journal))
+ journal.compatible_edi_ids += compatible_edis
+
+ @api.depends('type', 'company_id', 'company_id.country_id')
+ def _compute_edi_format_ids(self):
+ edi_formats = self.env['account.edi.format'].search([])
+
+ for journal in self:
+ journal.edi_format_ids += edi_formats.filtered(lambda e: e._is_compatible_with_journal(journal))
diff --git a/addons/account_edi/models/account_move.py b/addons/account_edi/models/account_move.py
new file mode 100644
index 00000000..3faa9e14
--- /dev/null
+++ b/addons/account_edi/models/account_move.py
@@ -0,0 +1,290 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ edi_document_ids = fields.One2many(
+ comodel_name='account.edi.document',
+ inverse_name='move_id')
+ edi_state = fields.Selection(
+ selection=[('to_send', 'To Send'), ('sent', 'Sent'), ('to_cancel', 'To Cancel'), ('cancelled', 'Cancelled')],
+ string="Electronic invoicing",
+ store=True,
+ compute='_compute_edi_state',
+ help='The aggregated state of all the EDIs of this move')
+ edi_error_count = fields.Integer(
+ compute='_compute_edi_error_count',
+ help='How many EDIs are in error for this move ?')
+ edi_web_services_to_process = fields.Text(
+ compute='_compute_edi_web_services_to_process',
+ help="Technical field to display the documents that will be processed by the CRON")
+ edi_show_cancel_button = fields.Boolean(
+ compute='_compute_edi_show_cancel_button')
+
+ @api.depends('edi_document_ids.state')
+ def _compute_edi_state(self):
+ for move in self:
+ all_states = set(move.edi_document_ids.filtered(lambda d: d.edi_format_id._needs_web_services()).mapped('state'))
+ if all_states == {'sent'}:
+ move.edi_state = 'sent'
+ elif all_states == {'cancelled'}:
+ move.edi_state = 'cancelled'
+ elif 'to_send' in all_states:
+ move.edi_state = 'to_send'
+ elif 'to_cancel' in all_states:
+ move.edi_state = 'to_cancel'
+ else:
+ move.edi_state = False
+
+ @api.depends('edi_document_ids.error')
+ def _compute_edi_error_count(self):
+ for move in self:
+ move.edi_error_count = len(move.edi_document_ids.filtered(lambda d: d.error))
+
+ @api.depends(
+ 'edi_document_ids',
+ 'edi_document_ids.state',
+ 'edi_document_ids.edi_format_id',
+ 'edi_document_ids.edi_format_id.name')
+ def _compute_edi_web_services_to_process(self):
+ for move in self:
+ to_process = move.edi_document_ids.filtered(lambda d: d.state in ['to_send', 'to_cancel'])
+ format_web_services = to_process.edi_format_id.filtered(lambda f: f._needs_web_services())
+ move.edi_web_services_to_process = ', '.join(f.name for f in format_web_services)
+
+ @api.depends('restrict_mode_hash_table', 'state')
+ def _compute_show_reset_to_draft_button(self):
+ # OVERRIDE
+ super()._compute_show_reset_to_draft_button()
+
+ for move in self:
+ for doc in move.edi_document_ids:
+ if doc.edi_format_id._needs_web_services() \
+ and doc.attachment_id \
+ and doc.state in ('sent', 'to_cancel') \
+ and move.is_invoice(include_receipts=True) \
+ and doc.edi_format_id._is_required_for_invoice(move):
+ move.show_reset_to_draft_button = False
+ break
+
+ @api.depends(
+ 'state',
+ 'edi_document_ids.state',
+ 'edi_document_ids.attachment_id')
+ def _compute_edi_show_cancel_button(self):
+ for move in self:
+ if move.state != 'posted':
+ move.edi_show_cancel_button = False
+ continue
+
+ move.edi_show_cancel_button = any([doc.edi_format_id._needs_web_services()
+ and doc.attachment_id
+ and doc.state == 'sent'
+ and move.is_invoice(include_receipts=True)
+ and doc.edi_format_id._is_required_for_invoice(move)
+ for doc in move.edi_document_ids])
+
+ ####################################################
+ # Export Electronic Document
+ ####################################################
+
+ def _update_payments_edi_documents(self):
+ ''' Update the edi documents linked to the current journal entries. These journal entries must be linked to an
+ account.payment of an account.bank.statement.line. This additional method is needed because the payment flow is
+ not the same as the invoice one. Indeed, the edi documents must be updated when the reconciliation with some
+ invoices is changing.
+ '''
+ edi_document_vals_list = []
+ for payment in self:
+ edi_formats = payment._get_reconciled_invoices().journal_id.edi_format_ids + payment.edi_document_ids.edi_format_id
+ edi_formats = self.env['account.edi.format'].browse(edi_formats.ids) # Avoid duplicates
+ for edi_format in edi_formats:
+ existing_edi_document = payment.edi_document_ids.filtered(lambda x: x.edi_format_id == edi_format)
+
+ if edi_format._is_required_for_payment(payment):
+ if existing_edi_document:
+ existing_edi_document.write({
+ 'state': 'to_send',
+ 'error': False,
+ 'blocking_level': False,
+ })
+ else:
+ edi_document_vals_list.append({
+ 'edi_format_id': edi_format.id,
+ 'move_id': payment.id,
+ 'state': 'to_send',
+ })
+ elif existing_edi_document:
+ existing_edi_document.write({
+ 'state': False,
+ 'error': False,
+ 'blocking_level': False,
+ })
+
+ self.env['account.edi.document'].create(edi_document_vals_list)
+ self.edi_document_ids._process_documents_no_web_services()
+
+ def _post(self, soft=True):
+ # OVERRIDE
+ # Set the electronic document to be posted and post immediately for synchronous formats.
+ posted = super()._post(soft=soft)
+
+ edi_document_vals_list = []
+ for move in posted:
+ for edi_format in move.journal_id.edi_format_ids:
+ is_edi_needed = move.is_invoice(include_receipts=False) and edi_format._is_required_for_invoice(move)
+
+ if is_edi_needed:
+ errors = edi_format._check_move_configuration(move)
+ if errors:
+ raise UserError(_("Invalid invoice configuration:\n\n%s") % '\n'.join(errors))
+
+ existing_edi_document = move.edi_document_ids.filtered(lambda x: x.edi_format_id == edi_format)
+ if existing_edi_document:
+ existing_edi_document.write({
+ 'state': 'to_send',
+ 'attachment_id': False,
+ })
+ else:
+ edi_document_vals_list.append({
+ 'edi_format_id': edi_format.id,
+ 'move_id': move.id,
+ 'state': 'to_send',
+ })
+
+ self.env['account.edi.document'].create(edi_document_vals_list)
+ posted.edi_document_ids._process_documents_no_web_services()
+ return posted
+
+ def button_cancel(self):
+ # OVERRIDE
+ # Set the electronic document to be canceled and cancel immediately for synchronous formats.
+ res = super().button_cancel()
+
+ self.edi_document_ids.filtered(lambda doc: doc.attachment_id).write({'state': 'to_cancel', 'error': False, 'blocking_level': False})
+ self.edi_document_ids.filtered(lambda doc: not doc.attachment_id).write({'state': 'cancelled', 'error': False, 'blocking_level': False})
+ self.edi_document_ids._process_documents_no_web_services()
+
+ return res
+
+ def button_draft(self):
+ # OVERRIDE
+ for move in self:
+ if move.edi_show_cancel_button:
+ raise UserError(_(
+ "You can't edit the following journal entry %s because an electronic document has already been "
+ "sent. Please use the 'Request EDI Cancellation' button instead."
+ ) % move.display_name)
+
+ res = super().button_draft()
+
+ self.edi_document_ids.write({'state': False, 'error': False, 'blocking_level': False})
+
+ return res
+
+ def button_cancel_posted_moves(self):
+ '''Mark the edi.document related to this move to be canceled.
+ '''
+ to_cancel_documents = self.env['account.edi.document']
+ for move in self:
+ is_move_marked = False
+ for doc in move.edi_document_ids:
+ if doc.edi_format_id._needs_web_services() \
+ and doc.attachment_id \
+ and doc.state == 'sent' \
+ and move.is_invoice(include_receipts=True) \
+ and doc.edi_format_id._is_required_for_invoice(move):
+ to_cancel_documents |= doc
+ is_move_marked = True
+ if is_move_marked:
+ move.message_post(body=_("A cancellation of the EDI has been requested."))
+
+ to_cancel_documents.write({'state': 'to_cancel', 'error': False, 'blocking_level': False})
+
+ def _get_edi_document(self, edi_format):
+ return self.edi_document_ids.filtered(lambda d: d.edi_format_id == edi_format)
+
+ def _get_edi_attachment(self, edi_format):
+ return self._get_edi_document(edi_format).attachment_id
+
+ ####################################################
+ # Import Electronic Document
+ ####################################################
+
+ def _get_create_invoice_from_attachment_decoders(self):
+ # OVERRIDE
+ res = super()._get_create_invoice_from_attachment_decoders()
+ res.append((10, self.env['account.edi.format'].search([])._create_invoice_from_attachment))
+ return res
+
+ def _get_update_invoice_from_attachment_decoders(self, invoice):
+ # OVERRIDE
+ res = super()._get_update_invoice_from_attachment_decoders(invoice)
+ res.append((10, self.env['account.edi.format'].search([])._update_invoice_from_attachment))
+ return res
+
+ ####################################################
+ # Business operations
+ ####################################################
+
+ def action_process_edi_web_services(self):
+ docs = self.edi_document_ids.filtered(lambda d: d.state in ('to_send', 'to_cancel'))
+ if 'blocking_level' in self.env['account.edi.document']._fields:
+ docs = docs.filtered(lambda d: d.blocking_level != 'error')
+ docs._process_documents_web_services(with_commit=False)
+
+class AccountMoveLine(models.Model):
+ _inherit = 'account.move.line'
+
+ ####################################################
+ # Export Electronic Document
+ ####################################################
+
+ def reconcile(self):
+ # OVERRIDE
+ # In some countries, the payments must be sent to the government under some condition. One of them could be
+ # there is at least one reconciled invoice to the payment. Then, we need to update the state of the edi
+ # documents during the reconciliation.
+ all_lines = self + self.matched_debit_ids.debit_move_id + self.matched_credit_ids.credit_move_id
+ payments = all_lines.move_id.filtered(lambda move: move.payment_id or move.statement_line_id)
+
+ invoices_per_payment_before = {pay: pay._get_reconciled_invoices() for pay in payments}
+ res = super().reconcile()
+ invoices_per_payment_after = {pay: pay._get_reconciled_invoices() for pay in payments}
+
+ changed_payments = self.env['account.move']
+ for payment, invoices_after in invoices_per_payment_after.items():
+ invoices_before = invoices_per_payment_before[payment]
+
+ if set(invoices_after.ids) != set(invoices_before.ids):
+ changed_payments |= payment
+ changed_payments._update_payments_edi_documents()
+
+ return res
+
+ def remove_move_reconcile(self):
+ # OVERRIDE
+ # When a payment has been sent to the government, it usually contains some information about reconciled
+ # invoices. If the user breaks a reconciliation, the related payments must be cancelled properly and then, a new
+ # electronic document must be generated.
+ all_lines = self + self.matched_debit_ids.debit_move_id + self.matched_credit_ids.credit_move_id
+ payments = all_lines.move_id.filtered(lambda move: move.payment_id or move.statement_line_id)
+
+ invoices_per_payment_before = {pay: pay._get_reconciled_invoices() for pay in payments}
+ res = super().remove_move_reconcile()
+ invoices_per_payment_after = {pay: pay._get_reconciled_invoices() for pay in payments}
+
+ changed_payments = self.env['account.move']
+ for payment, invoices_after in invoices_per_payment_after.items():
+ invoices_before = invoices_per_payment_before[payment]
+
+ if set(invoices_after.ids) != set(invoices_before.ids):
+ changed_payments |= payment
+ changed_payments._update_payments_edi_documents()
+
+ return res
diff --git a/addons/account_edi/models/account_payment.py b/addons/account_edi/models/account_payment.py
new file mode 100644
index 00000000..8b8a691d
--- /dev/null
+++ b/addons/account_edi/models/account_payment.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields, api, _
+
+
+class AccountPayment(models.Model):
+ _inherit = 'account.payment'
+
+ def action_process_edi_web_services(self):
+ return self.move_id.action_process_edi_web_services()
diff --git a/addons/account_edi/models/ir_actions_report.py b/addons/account_edi/models/ir_actions_report.py
new file mode 100644
index 00000000..9dcba740
--- /dev/null
+++ b/addons/account_edi/models/ir_actions_report.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+
+
+class IrActionsReport(models.Model):
+ _inherit = 'ir.actions.report'
+
+ def _post_pdf(self, save_in_attachment, pdf_content=None, res_ids=None):
+ # OVERRIDE to embed some EDI documents inside the PDF.
+ if self.model == 'account.move' and res_ids and len(res_ids) == 1 and pdf_content:
+ invoice = self.env['account.move'].browse(res_ids)
+ if invoice.is_sale_document() and invoice.state != 'draft':
+ pdf_content = invoice.journal_id.edi_format_ids._embed_edis_to_pdf(pdf_content, invoice)
+
+ return super(IrActionsReport, self)._post_pdf(save_in_attachment, pdf_content=pdf_content, res_ids=res_ids)
diff --git a/addons/account_edi/models/ir_attachment.py b/addons/account_edi/models/ir_attachment.py
new file mode 100644
index 00000000..e49e6946
--- /dev/null
+++ b/addons/account_edi/models/ir_attachment.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, _
+from odoo.exceptions import UserError
+
+
+class IrAttachment(models.Model):
+ _inherit = 'ir.attachment'
+
+ def unlink(self):
+ # OVERRIDE
+ linked_edi_documents = self.env['account.edi.document'].search([('attachment_id', 'in', self.ids)])
+ linked_edi_formats_ws = linked_edi_documents.edi_format_id.filtered(lambda edi_format: edi_format._needs_web_services())
+ if linked_edi_formats_ws:
+ raise UserError(_("You can't unlink an attachment being an EDI document sent to the government."))
+ return super().unlink()
diff --git a/addons/account_edi/models/mail_template.py b/addons/account_edi/models/mail_template.py
new file mode 100644
index 00000000..7c48fce3
--- /dev/null
+++ b/addons/account_edi/models/mail_template.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, models
+
+
+class MailTemplate(models.Model):
+ _inherit = "mail.template"
+
+ def generate_email(self, res_ids, fields):
+ res = super().generate_email(res_ids, fields)
+
+ multi_mode = True
+ if isinstance(res_ids, int):
+ res_ids = [res_ids]
+ multi_mode = False
+
+ if self.model not in ['account.move', 'account.payment']:
+ return res
+
+ records = self.env[self.model].browse(res_ids)
+ for record in records:
+ record_data = (res[record.id] if multi_mode else res)
+ for doc in record.edi_document_ids:
+
+ # The EDI format will be embedded directly inside the PDF and then, don't need to be added to the
+ # wizard.
+ if doc.edi_format_id._is_embedding_to_invoice_pdf_needed():
+ continue
+
+ attachment = doc.attachment_id
+ if attachment:
+ record_data.setdefault('attachments', [])
+ record_data['attachments'].append((attachment.name, attachment.datas))
+
+ return res