diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py
index 02a475c7323..9ca5d54d708 100644
--- a/addons/account/models/account_bank_statement.py
+++ b/addons/account/models/account_bank_statement.py
@@ -529,6 +529,7 @@ class AccountBankStatementLine(models.Model):
compute='_compute_is_reconciled',
help="Technical field indicating if the statement line is already reconciled.")
state = fields.Selection(related='statement_id.state', string='Status', readonly=True)
+ country_code = fields.Char(related='company_id.country_id.code')
# -------------------------------------------------------------------------
# HELPERS
diff --git a/addons/account/models/account_journal.py b/addons/account/models/account_journal.py
index 2a389ff33a9..9be7d9c68b5 100644
--- a/addons/account/models/account_journal.py
+++ b/addons/account/models/account_journal.py
@@ -101,6 +101,7 @@ class AccountJournal(models.Model):
currency_id = fields.Many2one('res.currency', help='The currency used to enter statement', string="Currency")
company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True, index=True, default=lambda self: self.env.company,
help="Company related to this journal")
+ country_code = fields.Char(related='company_id.country_id.code', readonly=True)
refund_sequence = fields.Boolean(string='Dedicated Credit Note Sequence', help="Check this box if you don't want to share the same sequence for invoices and credit notes made from this journal", default=False)
sequence_override_regex = fields.Text(help="Technical field used to enforce complex sequence composition that the system would normally misunderstand.\n"\
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index 99753a10e1d..3cea680f502 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -306,6 +306,7 @@ class AccountMove(models.Model):
help="Technical field used to display a message when the invoice's accounting date is prior of the tax lock date.")
# Technical field to hide Reconciled Entries stat button
has_reconciled_entries = fields.Boolean(compute="_compute_has_reconciled_entries")
+ show_reset_to_draft_button = fields.Boolean(compute='_compute_show_reset_to_draft_button')
# ==== Hash Fields ====
restrict_mode_hash_table = fields.Boolean(related='journal_id.restrict_mode_hash_table')
@@ -1430,6 +1431,11 @@ class AccountMove(models.Model):
else:
move.tax_lock_date_message = False
+ @api.depends('restrict_mode_hash_table', 'state')
+ def _compute_show_reset_to_draft_button(self):
+ for move in self:
+ move.show_reset_to_draft_button = not move.restrict_mode_hash_table and move.state in ('posted', 'cancel')
+
# -------------------------------------------------------------------------
# BUSINESS MODELS SYNCHRONIZATION
# -------------------------------------------------------------------------
diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py
index 1fbd206f050..a3f6d746212 100644
--- a/addons/account/models/account_payment.py
+++ b/addons/account/models/account_payment.py
@@ -125,6 +125,7 @@ class AccountPayment(models.Model):
require_partner_bank_account = fields.Boolean(
compute='_compute_show_require_partner_bank',
help="Technical field used to know whether the field `partner_bank_id` needs to be required or not in the payments form views")
+ country_code = fields.Char(related='company_id.country_id.code')
_sql_constraints = [
(
diff --git a/addons/account/models/account_tax.py b/addons/account/models/account_tax.py
index 4e389fcd89f..6d04db8b3c6 100644
--- a/addons/account/models/account_tax.py
+++ b/addons/account/models/account_tax.py
@@ -92,6 +92,7 @@ class AccountTax(models.Model):
invoice_repartition_line_ids = fields.One2many(string="Repartition for Invoices", comodel_name="account.tax.repartition.line", inverse_name="invoice_tax_id", copy=True, help="Repartition when the tax is used on an invoice")
refund_repartition_line_ids = fields.One2many(string="Repartition for Refund Invoices", comodel_name="account.tax.repartition.line", inverse_name="refund_tax_id", copy=True, help="Repartition when the tax is used on a refund")
tax_fiscal_country_id = fields.Many2one(string='Fiscal Country', comodel_name='res.country', related='company_id.account_tax_fiscal_country_id', help="Technical field used to restrict the domain of account tags for tax repartition lines created for this tax.")
+ country_code = fields.Char(related='company_id.country_id.code', readonly=True)
_sql_constraints = [
('name_company_uniq', 'unique(name, company_id, type_tax_use, tax_scope)', 'Tax names must be unique !'),
diff --git a/addons/account/tests/account_test_xml.py b/addons/account/tests/account_test_xml.py
deleted file mode 100644
index 04fc717a12c..00000000000
--- a/addons/account/tests/account_test_xml.py
+++ /dev/null
@@ -1,96 +0,0 @@
-# -*- coding: utf-8 -*-
-from odoo.addons.account.tests.common import AccountTestInvoicingCommon
-
-import base64
-
-from lxml import etree
-
-
-class AccountTestEdiCommon(AccountTestInvoicingCommon):
-
- def assertXmlTreeEqual(self, xml_tree, expected_xml_tree):
- ''' Compare two lxml.etree.
- :param xml_tree: The current tree.
- :param expected_xml_tree: The expected tree.
- '''
-
- def _turn_node_as_dict_hierarchy(node):
- ''' Turn the node as a python dictionary to be compared later with another one.
- Allow to ignore the management of namespaces.
- :param node: A node inside an xml tree.
- :return: A python dictionary.
- '''
- tag_split = node.tag.split('}')
- tag_wo_ns = tag_split[-1]
- attrib_wo_ns = {k: v for k, v in node.attrib.items() if '}' not in k}
- return {
- 'tag': tag_wo_ns,
- 'namespace': None if len(tag_split) < 2 else tag_split[0],
- 'text': (node.text or '').strip(),
- 'attrib': attrib_wo_ns,
- 'children': [_turn_node_as_dict_hierarchy(child_node) for child_node in node.getchildren()],
- }
-
- def assertNodeDictEqual(node_dict, expected_node_dict):
- ''' Compare nodes created by the `_turn_node_as_dict_hierarchy` method.
- :param node_dict: The node to compare with.
- :param expected_node_dict: The expected node.
- '''
- # Check tag.
- self.assertEqual(node_dict['tag'], expected_node_dict['tag'])
-
- # Check attributes.
- node_dict_attrib = {k: '___ignore___' if expected_node_dict['attrib'].get(k) == '___ignore___' else v
- for k, v in node_dict['attrib'].items()}
- expected_node_dict_attrib = {k: v for k, v in expected_node_dict['attrib'].items() if v != '___remove___'}
- self.assertDictEqual(
- node_dict_attrib,
- expected_node_dict_attrib,
- "Element attributes are different for node %s" % node_dict['tag'],
- )
-
- # Check text.
- if expected_node_dict['text'] != '___ignore___':
- self.assertEqual(
- node_dict['text'],
- expected_node_dict['text'],
- "Element text are different for node %s" % node_dict['tag'],
- )
-
- # Check children.
- self.assertEqual(
- [child['tag'] for child in node_dict['children']],
- [child['tag'] for child in expected_node_dict['children']],
- "Number of children elements for node %s is different." % node_dict['tag'],
- )
-
- for child_node_dict, expected_child_node_dict in zip(node_dict['children'], expected_node_dict['children']):
- assertNodeDictEqual(child_node_dict, expected_child_node_dict)
-
- assertNodeDictEqual(
- _turn_node_as_dict_hierarchy(xml_tree),
- _turn_node_as_dict_hierarchy(expected_xml_tree),
- )
-
- def with_applied_xpath(self, xml_tree, xpath):
- ''' Applies the xpath to the xml_tree passed as parameter.
- :param xml_tree: An instance of etree.
- :param xpath: The xpath to apply as a string.
- :return: The resulting etree after applying the xpaths.
- '''
- diff_xml_tree = etree.fromstring('%s' % xpath)
- return self.env['ir.ui.view'].apply_inheritance_specs(xml_tree, diff_xml_tree)
-
- def get_xml_tree_from_attachment(self, attachment):
- ''' Extract an instance of etree from an ir.attachment.
- :param attachment: An ir.attachment.
- :return: An instance of etree.
- '''
- return etree.fromstring(base64.b64decode(attachment.with_context(bin_size=False).datas))
-
- def get_xml_tree_from_string(self, xml_tree_str):
- ''' Convert the string passed as parameter to an instance of etree.
- :param xml_tree_str: A string representing an xml.
- :return: An instance of etree.
- '''
- return etree.fromstring(xml_tree_str)
diff --git a/addons/account/tests/common.py b/addons/account/tests/common.py
index fdb28764ff3..51bcecb9d01 100644
--- a/addons/account/tests/common.py
+++ b/addons/account/tests/common.py
@@ -4,7 +4,8 @@ from odoo import fields
from odoo.tests.common import SavepointCase, HttpSavepointCase, tagged, Form
import time
-
+import base64
+from lxml import etree
@tagged('post_install', '-at_install')
class AccountTestInvoicingCommon(SavepointCase):
@@ -45,7 +46,7 @@ class AccountTestInvoicingCommon(SavepointCase):
cls.cr = cls.env.cr
cls.company_data_2 = cls.setup_company_data('company_2_data', chart_template=chart_template)
- cls.company_data = cls.setup_company_data('company_1_data', chart_template=chart_template)
+ cls.company_data = cls.setup_company_data('company_1_data', chart_template=chart_template)
user.write({
'company_ids': [(6, 0, (cls.company_data['company'] + cls.company_data_2['company']).ids)],
@@ -375,6 +376,97 @@ class AccountTestInvoicingCommon(SavepointCase):
self.assertRecordValues(sort_lines(move.invoice_line_ids.sorted()), expected_lines_values[:len(move.invoice_line_ids)])
self.assertRecordValues(move, [expected_move_values])
+ ####################################################
+ # Xml Comparison
+ ####################################################
+
+ def _turn_node_as_dict_hierarchy(self, node):
+ ''' Turn the node as a python dictionary to be compared later with another one.
+ Allow to ignore the management of namespaces.
+ :param node: A node inside an xml tree.
+ :return: A python dictionary.
+ '''
+ tag_split = node.tag.split('}')
+ tag_wo_ns = tag_split[-1]
+ attrib_wo_ns = {k: v for k, v in node.attrib.items() if '}' not in k}
+ return {
+ 'tag': tag_wo_ns,
+ 'namespace': None if len(tag_split) < 2 else tag_split[0],
+ 'text': (node.text or '').strip(),
+ 'attrib': attrib_wo_ns,
+ 'children': [self._turn_node_as_dict_hierarchy(child_node) for child_node in node.getchildren()],
+ }
+
+ def assertXmlTreeEqual(self, xml_tree, expected_xml_tree):
+ ''' Compare two lxml.etree.
+ :param xml_tree: The current tree.
+ :param expected_xml_tree: The expected tree.
+ '''
+
+ def assertNodeDictEqual(node_dict, expected_node_dict):
+ ''' Compare nodes created by the `_turn_node_as_dict_hierarchy` method.
+ :param node_dict: The node to compare with.
+ :param expected_node_dict: The expected node.
+ '''
+ # Check tag.
+ self.assertEqual(node_dict['tag'], expected_node_dict['tag'])
+
+ # Check attributes.
+ node_dict_attrib = {k: '___ignore___' if expected_node_dict['attrib'].get(k) == '___ignore___' else v
+ for k, v in node_dict['attrib'].items()}
+ expected_node_dict_attrib = {k: v for k, v in expected_node_dict['attrib'].items() if v != '___remove___'}
+ self.assertDictEqual(
+ node_dict_attrib,
+ expected_node_dict_attrib,
+ "Element attributes are different for node %s" % node_dict['tag'],
+ )
+
+ # Check text.
+ if expected_node_dict['text'] != '___ignore___':
+ self.assertEqual(
+ node_dict['text'],
+ expected_node_dict['text'],
+ "Element text are different for node %s" % node_dict['tag'],
+ )
+
+ # Check children.
+ self.assertEqual(
+ [child['tag'] for child in node_dict['children']],
+ [child['tag'] for child in expected_node_dict['children']],
+ "Number of children elements for node %s is different." % node_dict['tag'],
+ )
+
+ for child_node_dict, expected_child_node_dict in zip(node_dict['children'], expected_node_dict['children']):
+ assertNodeDictEqual(child_node_dict, expected_child_node_dict)
+
+ assertNodeDictEqual(
+ self._turn_node_as_dict_hierarchy(xml_tree),
+ self._turn_node_as_dict_hierarchy(expected_xml_tree),
+ )
+
+ def with_applied_xpath(self, xml_tree, xpath):
+ ''' Applies the xpath to the xml_tree passed as parameter.
+ :param xml_tree: An instance of etree.
+ :param xpath: The xpath to apply as a string.
+ :return: The resulting etree after applying the xpaths.
+ '''
+ diff_xml_tree = etree.fromstring('%s' % xpath)
+ return self.env['ir.ui.view'].apply_inheritance_specs(xml_tree, diff_xml_tree)
+
+ def get_xml_tree_from_attachment(self, attachment):
+ ''' Extract an instance of etree from an ir.attachment.
+ :param attachment: An ir.attachment.
+ :return: An instance of etree.
+ '''
+ return etree.fromstring(base64.b64decode(attachment.with_context(bin_size=False).datas))
+
+ def get_xml_tree_from_string(self, xml_tree_str):
+ ''' Convert the string passed as parameter to an instance of etree.
+ :param xml_tree_str: A string representing an xml.
+ :return: An instance of etree.
+ '''
+ return etree.fromstring(xml_tree_str)
+
@tagged('post_install', '-at_install')
class AccountTestInvoicingHttpCommon(AccountTestInvoicingCommon, HttpSavepointCase):
diff --git a/addons/account/views/account_bank_statement_views.xml b/addons/account/views/account_bank_statement_views.xml
index 129ececf2cf..55308646a95 100644
--- a/addons/account/views/account_bank_statement_views.xml
+++ b/addons/account/views/account_bank_statement_views.xml
@@ -196,6 +196,7 @@
+
diff --git a/addons/account/views/account_journal_views.xml b/addons/account/views/account_journal_views.xml
index 2567bf53215..7a829dfb76f 100644
--- a/addons/account/views/account_journal_views.xml
+++ b/addons/account/views/account_journal_views.xml
@@ -36,6 +36,7 @@
+
diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml
index 2a70e60d9a3..c9e12964093 100644
--- a/addons/account/views/account_move_views.xml
+++ b/addons/account/views/account_move_views.xml
@@ -523,7 +523,7 @@
+ attrs="{'invisible' : [('show_reset_to_draft_button', '=', False)]}"/>
@@ -584,6 +584,7 @@
+
@@ -671,7 +672,7 @@
-
+
diff --git a/addons/account/wizard/account_payment_register.py b/addons/account/wizard/account_payment_register.py
index 0d996f20ddc..459d8097f66 100644
--- a/addons/account/wizard/account_payment_register.py
+++ b/addons/account/wizard/account_payment_register.py
@@ -103,6 +103,7 @@ class AccountPaymentRegister(models.TransientModel):
require_partner_bank_account = fields.Boolean(
compute='_compute_show_require_partner_bank',
help="Technical field used to know whether the field `partner_bank_id` needs to be required or not in the payments form views")
+ country_code = fields.Char(related='company_id.country_id.code', readonly=True)
# -------------------------------------------------------------------------
# HELPERS
diff --git a/addons/account/wizard/account_payment_register_views.xml b/addons/account/wizard/account_payment_register_views.xml
index 15aee188635..edf4f5db2c0 100644
--- a/addons/account/wizard/account_payment_register_views.xml
+++ b/addons/account/wizard/account_payment_register_views.xml
@@ -18,6 +18,7 @@
+
diff --git a/addons/account_edi/__manifest__.py b/addons/account_edi/__manifest__.py
index 5234e79dc12..08efe0e3ca1 100644
--- a/addons/account_edi/__manifest__.py
+++ b/addons/account_edi/__manifest__.py
@@ -16,7 +16,11 @@ governements, etc.)
'data': [
'security/ir.model.access.csv',
'wizard/account_invoice_send_views.xml',
- 'views/account_journal_views.xml'
+ 'views/account_edi_document_views.xml',
+ 'views/account_move_views.xml',
+ 'views/account_payment_views.xml',
+ 'views/account_journal_views.xml',
+ 'data/cron.xml'
],
'installable': True,
'application': False,
diff --git a/addons/account_edi/data/cron.xml b/addons/account_edi/data/cron.xml
new file mode 100644
index 00000000000..2aebca66a78
--- /dev/null
+++ b/addons/account_edi/data/cron.xml
@@ -0,0 +1,11 @@
+
+
+ EDI : Perform web services operations
+
+ code
+ model.search([('state', 'in', ('to_send', 'to_cancel'))])._process_documents_web_services(job_count=20)
+ 1
+ minutes
+ -1
+
+
diff --git a/addons/account_edi/models/__init__.py b/addons/account_edi/models/__init__.py
index dea47dee7b9..e2229d0dd2f 100644
--- a/addons/account_edi/models/__init__.py
+++ b/addons/account_edi/models/__init__.py
@@ -3,6 +3,7 @@
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 ir_attachment
from . import mail_template
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 00000000000..9e5dd2fbfed
--- /dev/null
+++ b/addons/account_edi/models/account_edi_document.py
@@ -0,0 +1,177 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields, api, _
+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')
+ edi_format_id = fields.Many2one('account.edi.format')
+ 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()
+
+ # == 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 _prepare_jobs(self):
+ """Creates a list of jobs to be performed by '_process_jobs' 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 (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 must be one
+ """
+
+ to_process = []
+ batches = {}
+ for edi_doc in self.filtered(lambda d: d.state in ('to_send', 'to_cancel')):
+ 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
+
+ key = (edi_format, edi_doc.state, doc_type, move.company_id)
+ if edi_format._support_batching():
+ if not batches.get(key, None):
+ batches[key] = self.env['account.edi.document']
+ batches[key] |= edi_doc
+ else:
+ to_process.append((key, edi_doc))
+ to_process.extend(batches.items())
+ return to_process
+
+ @api.model
+ def _process_jobs(self, to_process):
+ """Post or cancel move_id (invoice or payment) by calling the related methods on edi_format_id.
+ Invoices are processed before payments.
+ """
+ def _postprocess_post_edi_results(documents, edi_result):
+ for document in documents:
+ move = document.move_id
+ move_result = edi_result.get(move, {})
+ if move_result.get('attachment'):
+ document.write({
+ 'attachment_id': move_result['attachment'].id,
+ 'state': 'sent',
+ 'error': False,
+ })
+ else:
+ document.error = move_result.get('error', _("Error when processing the journal entry."))
+
+ def _postprocess_cancel_edi_results(documents, edi_result):
+ invoice_ids_to_cancel = set() # Avoid duplicates
+ for document in documents:
+ move = document.move_id
+ move_result = edi_result.get(move, {})
+ if move_result.get('success'):
+ document.write({
+ 'state': 'cancelled',
+ 'error': False,
+ 'attachment_id': False,
+ })
+
+ # The user requested a cancellation of the EDI and it has been approved. Then, the invoice
+ # can be safely cancelled.
+ if move.is_invoice(include_receipts=True) and move.state == 'posted':
+ invoice_ids_to_cancel.add(move.id)
+
+ else:
+ document.error = move_result.get('error') or _("Error when cancelling the journal entry.")
+
+ if invoice_ids_to_cancel:
+ invoices = self.env['account.move'].browse(list(invoice_ids_to_cancel))
+ invoices.button_draft()
+ invoices.button_cancel()
+
+ test_mode = self._context.get('edi_test_mode', False)
+
+ # ==== Process invoices ====
+ payments = []
+ for key, batches in to_process:
+ edi_format, state, doc_type, company_id = key
+ if doc_type == 'payment':
+ payments.append((key, batches))
+ continue # payments are processed after invoices
+
+ for documents in batches:
+ try:
+ with self.env.cr.savepoint():
+ # Locks the documents in DB. Avoid sending an invoice twice (the documents can be processed by the CRON but also manually).
+ self._cr.execute('SELECT * FROM account_edi_document WHERE id IN %s FOR UPDATE NOWAIT', [tuple(self.ids)])
+
+ 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)
+
+ except OperationalError as e:
+ if e.pgcode == '55P03':
+ _logger.debug('Another transaction already locked documents rows. Cannot process documents.')
+ else:
+ raise e
+
+ # ==== Process payments ====
+ for key, batches in payments:
+ edi_format, state, doc_type, company_id = key
+
+ for documents in batches:
+ try:
+ with self.env.cr.savepoint():
+ self._cr.execute('SELECT * FROM account_edi_document WHERE id IN %s FOR UPDATE NOWAIT', [tuple(self.ids)])
+
+ 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)
+
+ except OperationalError as e:
+ if e.pgcode == '55P03':
+ _logger.debug('Another transaction already locked documents rows. Cannot process documents.')
+ else:
+ raise e
+
+ 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(jobs)
+
+ def _process_documents_web_services(self, job_count=None):
+ """ 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()
+ self._process_jobs(jobs[0:job_count or len(jobs)])
diff --git a/addons/account_edi/models/account_edi_format.py b/addons/account_edi/models/account_edi_format.py
index c36f22e16a3..38babb16814 100644
--- a/addons/account_edi/models/account_edi_format.py
+++ b/addons/account_edi/models/account_edi_format.py
@@ -19,51 +19,140 @@ class AccountEdiFormat(models.Model):
name = fields.Char()
code = fields.Char()
- hide_on_journal = fields.Selection([('import_export', 'Import/Export'), ('import', 'Import Only')], default='import_export', help='used to hide this EDI format on journals')
_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)
+
+ 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
+
+ return edi_formats
+
####################################################
# Export method to override based on EDI Format
####################################################
- def _export_invoice_to_attachment(self, invoice):
- """ Create the file content representing the invoice.
+ def _is_required_for_invoice(self, invoice):
+ """ Indicate if this EDI must be generated for the invoice passed as parameter.
- :param invoice: the invoice to encode.
- :returns: a dictionary (values are compatible to create an ir.attachment)
- * name : the name of the file
- * datas : the content of the file,
- * res_model : 'account.move',
- * res_id: the id of invoice
- * mimetype : the mimetype of the attachment
+ :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 _export_invoice_to_embed_to_pdf(self, pdf_content, invoice):
- """ Create the file content representing the invoice when it's destined
- to be embed into a pdf.
- - default: creates the default EDI document (_export_invoice_to_attachment).
- - Should return False if this EDI format should not be embedded.
- - Should be overriden only if a specific behavior (for example,
- include the pdf content inside the file).
+ def _needs_web_services(self):
+ """ Indicate if the EDI must be generated asynchronously through to some web services.
- :param pdf_content: the pdf before any EDI format was added.
- :param invoice: the invoice to add.
- :returns: a dictionary or False if this EDI format must not be embedded to pdf.
- * name : the name of the file
- * datas : the content of the file,
- * res_model : 'account.move',
- * res_id: the id of invoice
- * mimetype : the mimetype of the attachment
+ :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 self._export_invoice_to_attachment(invoice)
+ 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 _support_batching(self):
+ """ 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 _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.
+ """
+ # 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.
+ """
+ # 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.
+ """
+ # 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.
+ """
+ # 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
@@ -129,12 +218,10 @@ class AccountEdiFormat(models.Model):
"""
attachments = []
for edi_format in self:
- try:
- vals = edi_format._export_invoice_to_embed_to_pdf(pdf_content, invoice)
- except:
- continue
- if vals:
- attachments.append(vals)
+ attachment = invoice.edi_document_ids.filtered(lambda d: d.edi_format_id == edi_format).attachment_id
+ if attachment and edi_format._is_embedding_to_invoice_pdf_needed():
+ datas = base64.b64decode(attachment.with_context(bin_size=False).datas)
+ attachments.append({'name': attachment.name, 'datas': datas})
if attachments:
# Add the attachments to the pdf file
@@ -151,23 +238,6 @@ class AccountEdiFormat(models.Model):
buffer.close()
return pdf_content
- def _create_ir_attachments(self, invoice):
- """ Create ir.attachment for the EDIs from invoice.
-
- :param invoice: the invoice to generate the EDI from.
- :returns: the newly created attachments.
- """
- attachment_vals_list = []
- for edi_format in self:
- vals = edi_format._export_invoice_to_attachment(invoice)
- if vals:
- vals['datas'] = base64.encodebytes(vals['datas'])
- vals['edi_format_id'] = edi_format._origin.id
- attachment_vals_list.append(vals)
- res = self.env['ir.attachment'].create(attachment_vals_list)
- invoice.edi_document_ids |= res
- return res
-
####################################################
# Import Internal methods (not meant to be overridden)
####################################################
@@ -268,7 +338,7 @@ class AccountEdiFormat(models.Model):
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()
+ file_data['pdf_reader'].streamS.close()
if res:
if 'extract_state' in res:
# Bypass the OCR to prevent overwriting data when an EDI was succesfully imported.
diff --git a/addons/account_edi/models/account_journal.py b/addons/account_edi/models/account_journal.py
index c160d8da249..ecc82632f99 100644
--- a/addons/account_edi/models/account_journal.py
+++ b/addons/account_edi/models/account_journal.py
@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import models, fields
+from odoo import api, models, fields, _
+from odoo.exceptions import UserError
class AccountJournal(models.Model):
@@ -10,12 +11,52 @@ class AccountJournal(models.Model):
edi_format_ids = fields.Many2many(comodel_name='account.edi.format',
string='Electronic invoicing',
help='Send XML/EDI invoices',
- domain="[('hide_on_journal', '=', 'import_export')]")
+ 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))
def _create_invoice_from_single_attachment(self, attachment):
+ # OVERRIDE
invoice = self.env['account.edi.format'].search([])._create_invoice_from_attachment(attachment)
if invoice:
# with_context: we don't want to import the attachment since the invoice was just created from it.
- invoice.with_context(no_new_invoice=True).message_post(attachment_ids=[attachment.id])
+ invoice.with_context(no_new_invoice=True).message_post(attachment_ids=attachment.ids)
return invoice
return super()._create_invoice_from_single_attachment(attachment)
diff --git a/addons/account_edi/models/account_move.py b/addons/account_edi/models/account_move.py
index 76bc9309611..2f35c80a017 100644
--- a/addons/account_edi/models/account_move.py
+++ b/addons/account_edi/models/account_move.py
@@ -1,34 +1,214 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import api, models, fields
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
class AccountMove(models.Model):
_inherit = 'account.move'
- edi_document_ids = fields.One2many('ir.attachment', 'res_id', domain=[('res_model', '=', 'account.move'), ('edi_format_id', '!=', False)])
+ 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.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,
+ })
+ 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,
+ })
+
+ 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
- # Generate the electronic documents for the move.
- existing_attachments = self.env['ir.attachment'].search([
- ('res_model', '=', 'account.move'),
- ('res_id', 'in', self.ids),
- ('edi_format_id', 'in', self.journal_id.edi_format_ids.ids)])
- existing_attachments.unlink()
- posted = super()._post(soft)
- for move in posted:
- move.journal_id.edi_format_ids._create_ir_attachments(move)
+ # Set the electronic document to be posted and post immediately for synchronous formats.
+ res = super()._post(soft=soft)
- return posted
+ edi_document_vals_list = []
+ for move in self:
+ 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:
+ 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)
+ self.edi_document_ids._process_documents_no_web_services()
+ return res
+
+ 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})
+ self.edi_document_ids.filtered(lambda doc: not doc.attachment_id).write({'state': 'cancelled', 'error': False})
+
+ 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})
+
+ 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})
+
+ ####################################################
+ # Import Electronic Document
+ ####################################################
@api.returns('mail.message', lambda value: value.id)
def message_post(self, **kwargs):
# OVERRIDE
# When posting a message, analyse the attachment to check if it is an EDI document and update the invoice
- # with the data.
- res = super(AccountMove, self).message_post(**kwargs)
+ # with the imported data.
+ res = super().message_post(**kwargs)
if len(self) != 1 or self.env.context.get('no_new_invoice') or not self.is_invoice(include_receipts=True):
return res
@@ -48,8 +228,69 @@ class AccountMove(models.Model):
author_id=odoobot.id)
return res
+ edi_formats = self.env['account.edi.format'].search([])
for attachment in attachments:
- invoice = self.env['account.edi.format'].search([])._update_invoice_from_attachment(attachment, self)
- break
+ invoice = edi_formats._update_invoice_from_attachment(attachment, self)
+ if invoice:
+ break
+
+ return res
+
+ ####################################################
+ # Business operations
+ ####################################################
+
+ def action_process_edi_web_services(self):
+ self.edi_document_ids.filtered(lambda d: d.state in ('to_send', 'to_cancel'))._process_documents_web_services()
+
+
+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 00000000000..8b8a691d63e
--- /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_attachment.py b/addons/account_edi/models/ir_attachment.py
deleted file mode 100644
index 146411bc28e..00000000000
--- a/addons/account_edi/models/ir_attachment.py
+++ /dev/null
@@ -1,10 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from odoo import models, fields
-
-
-class IrAttachment(models.Model):
- _inherit = 'ir.attachment'
-
- edi_format_id = fields.Many2one('account.edi.format')
diff --git a/addons/account_edi/models/mail_template.py b/addons/account_edi/models/mail_template.py
index 209b9160e0e..3b294b1ef9c 100644
--- a/addons/account_edi/models/mail_template.py
+++ b/addons/account_edi/models/mail_template.py
@@ -17,16 +17,18 @@ class MailTemplate(models.Model):
if self.model != 'account.move':
return res
- existing_attachments = self.env['ir.attachment'].search([
- ('res_model', '=', 'account.move'),
- ('res_id', 'in', res_ids),
- ('edi_format_id', '!=', False)])
+ 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:
- for record in self.env[self.model].browse(res_ids):
- available_formats = existing_attachments.filtered(lambda a: a.res_id == record.id)
- missing_formats = record.journal_id.edi_format_ids.filtered(lambda f: f._origin.id not in available_formats.edi_format_id.ids)
- new_attachments = missing_formats._create_ir_attachments(record)
- available_formats |= new_attachments
- (res[record.id] if multi_mode else res).setdefault('attachments', []).extend([(a.name, a.datas) for a in available_formats])
+ # 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
+ record_data.setdefault('attachments', [])
+ record_data['attachments'].append((attachment.name, attachment.datas))
return res
diff --git a/addons/account_edi/security/ir.model.access.csv b/addons/account_edi/security/ir.model.access.csv
index 71c1f8bda06..907e686054c 100644
--- a/addons/account_edi/security/ir.model.access.csv
+++ b/addons/account_edi/security/ir.model.access.csv
@@ -1,2 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_account_edi_format_readonly,account.edi.format,model_account_edi_format,,1,0,0,0
+access_account_edi_format_group_invoice,account.edi.format,model_account_edi_format,account.group_account_invoice,1,1,1,1
+access_account_edi_document_readonly,account.edi.document,model_account_edi_document,,1,0,0,0
+access_account_edi_document_group_invoice,account.edi.document,model_account_edi_document,account.group_account_invoice,1,1,1,1
diff --git a/addons/account_edi/tests/__init__.py b/addons/account_edi/tests/__init__.py
new file mode 100644
index 00000000000..eea5d7edcd2
--- /dev/null
+++ b/addons/account_edi/tests/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import common
+from . import test_edi
diff --git a/addons/account_edi/tests/common.py b/addons/account_edi/tests/common.py
new file mode 100644
index 00000000000..d6fe883fecd
--- /dev/null
+++ b/addons/account_edi/tests/common.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo.modules.module import get_module_resource
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+
+from contextlib import contextmanager
+from unittest.mock import patch
+from unittest import mock
+
+import base64
+
+
+class AccountEdiTestCommon(AccountTestInvoicingCommon):
+
+ @classmethod
+ def setUpClass(cls, chart_template_ref=None, edi_format_ref=None):
+ super().setUpClass(chart_template_ref=chart_template_ref)
+
+ # ==== EDI ====
+ if edi_format_ref:
+ cls.edi_format = cls.env.ref(edi_format_ref)
+ else:
+ cls.edi_format = cls.env['account.edi.format'].sudo().create({
+ 'name': 'Test EDI format',
+ 'code': 'test_edi',
+ })
+ cls.journal = cls.company_data['default_journal_sale']
+ cls.journal.edi_format_ids = [(6, 0, cls.edi_format.ids)]
+
+ ####################################################
+ # EDI helpers
+ ####################################################
+
+ def edi_cron(self):
+ self.env['account.edi.document'].sudo().with_context(edi_test_mode=True).search([('state', 'in', ('to_send', 'to_cancel'))])._process_documents_web_services()
+
+ def update_invoice_from_file(self, module_name, subfolder, filename, invoice):
+ file_path = get_module_resource(module_name, subfolder, filename)
+ file = open(file_path, 'rb').read()
+
+ attachment = self.env['ir.attachment'].create({
+ 'name': filename,
+ 'datas': base64.encodebytes(file),
+ 'res_id': invoice.id,
+ 'res_model': 'account.move',
+ })
+
+ invoice.message_post(attachment_ids=[attachment.id])
+
+ def create_invoice_from_file(self, module_name, subfolder, filename):
+ file_path = get_module_resource(module_name, 'test_file', filename)
+ file = open(file_path, 'rb').read()
+
+ attachment = self.env['ir.attachment'].create({
+ 'name': filename,
+ 'datas': base64.encodebytes(file),
+ 'res_model': 'account.move',
+ })
+
+ journal_id = self.company_data['default_journal_sale']
+ invoice = journal_id.with_context(default_move_type='in_invoice')._create_invoice_from_single_attachment(attachment)
+
+ def assert_generated_file_equal(self, invoice, expected_values, applied_xpath=None):
+ invoice.action_post()
+ invoice.edi_document_ids._process_documents_web_services() # synchronous are called in post, but there's no CRON in tests for asynchronous
+ attachment = invoice.edi_document_ids.filtered(lambda d: d.edi_format_id == self.edi_format).attachment_id
+ xml_content = base64.b64decode(attachment.with_context(bin_size=False).datas)
+ current_etree = self.get_xml_tree_from_string(xml_content)
+ expected_etree = self.get_xml_tree_from_string(expected_values)
+ if applied_xpath:
+ expected_etree = self.with_applied_xpath(expected_etree, applied_xpath)
+ self.assertXmlTreeEqual(current_etree, expected_etree)
+
+ def _process_documents_web_services(self, moves):
+ moves.edi_document_ids.with_context(edi_test_mode=True)._process_documents_web_services()
+
+ attachments = moves.edi_document_ids.attachment_id
+
+ data_str_list = []
+ for attachment in attachments.with_context(bin_size=False):
+ data_str_list.append(base64.decodebytes(attachment.datas))
+ return data_str_list
diff --git a/addons/account_edi/tests/test_edi.py b/addons/account_edi/tests/test_edi.py
new file mode 100644
index 00000000000..9d15b5ffec4
--- /dev/null
+++ b/addons/account_edi/tests/test_edi.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.account_edi.tests.common import AccountEdiTestCommon
+from unittest.mock import patch
+
+
+class TestAccountEdi(AccountEdiTestCommon):
+
+ def test_export_edi(self):
+ invoice = self.init_invoice('out_invoice')
+ self.assertEqual(len(invoice.edi_document_ids), 0)
+ invoice.action_post()
+ self.assertEqual(len(invoice.edi_document_ids), 1)
+
+ def test_prepare_jobs(self):
+ def create_edi_document(edi_format, state, move=None, move_type=None):
+ move = move or self.init_invoice(move_type or 'out_invoice')
+ return self.env['account.edi.document'].create({
+ 'edi_format_id': edi_format.id,
+ 'move_id': move.id,
+ 'state': state
+ })
+
+ edi_docs = self.env['account.edi.document']
+ edi_docs |= create_edi_document(self.edi_format, 'to_send')
+ edi_docs |= create_edi_document(self.edi_format, 'to_send')
+
+ to_process = edi_docs._prepare_jobs()
+ self.assertEqual(len(to_process), 2)
+
+ with patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._support_batching', return_value=True):
+ to_process = edi_docs._prepare_jobs()
+ self.assertEqual(len(to_process), 1)
+
+ other_edi = self.env['account.edi.format'].sudo().create({
+ 'name': 'Batchable EDI format 2',
+ 'code': 'test_batch_edi_2',
+ })
+
+ edi_docs |= create_edi_document(other_edi, 'to_send')
+ edi_docs |= create_edi_document(other_edi, 'to_send')
+
+ with patch('odoo.addons.account_edi.models.account_edi_format.AccountEdiFormat._support_batching', return_value=True):
+ to_process = edi_docs._prepare_jobs()
+ self.assertEqual(len(to_process), 2)
diff --git a/addons/account_edi/views/account_edi_document_views.xml b/addons/account_edi/views/account_edi_document_views.xml
new file mode 100644
index 00000000000..9b16ff60deb
--- /dev/null
+++ b/addons/account_edi/views/account_edi_document_views.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ Account.edi.document.tree
+ account.edi.document
+
+
+
+
+
+
+
+
+
diff --git a/addons/account_edi/views/account_journal_views.xml b/addons/account_edi/views/account_journal_views.xml
index 5ed896b54bc..09ba292e7bb 100644
--- a/addons/account_edi/views/account_journal_views.xml
+++ b/addons/account_edi/views/account_journal_views.xml
@@ -6,9 +6,10 @@
account.journal
-
-
-
+
+
+
+
diff --git a/addons/account_edi/views/account_move_views.xml b/addons/account_edi/views/account_move_views.xml
new file mode 100644
index 00000000000..a5d8acdfe1a
--- /dev/null
+++ b/addons/account_edi/views/account_move_views.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+ Electronic invoicing
+ account.edi.document
+ tree
+ [('move_id', '=', active_id), ('error', '!=', False)]
+
+
+
+ account.move.form.inherit
+ account.move
+
+
+
+
+
+
+
+