From 013936261b26c601ce55015dbbbb99a7313a9a39 Mon Sep 17 00:00:00 2001 From: "Benjamin Frantzen (bfr)" Date: Tue, 26 May 2020 12:53:23 +0000 Subject: [PATCH] [IMP] account_edi: added flows to manage web-services and payments. Added edi.documents representing an electronic document for a move and an edi.format. A format can be asynchronous if it needs to call a web-service to generate the document, it will then be generated by the CRON (otherwise it's generated in post). The formats can support payments if needed (can be generated immediately or by the CRON). Added support for errors and related views. Setting defaults format on a journal can be done automatically (based on a hook). Added tests : xml comparaison with diff and helpers to test a EDI import/export --task: 2247368 --- .../account/models/account_bank_statement.py | 1 + addons/account/models/account_journal.py | 1 + addons/account/models/account_move.py | 6 + addons/account/models/account_payment.py | 1 + addons/account/models/account_tax.py | 1 + addons/account/tests/account_test_xml.py | 96 ------ addons/account/tests/common.py | 96 +++++- .../views/account_bank_statement_views.xml | 1 + .../account/views/account_journal_views.xml | 1 + addons/account/views/account_move_views.xml | 5 +- addons/account/views/account_payment_view.xml | 1 + addons/account/views/account_tax_views.xml | 1 + .../wizard/account_payment_register.py | 1 + .../wizard/account_payment_register_views.xml | 1 + addons/account_edi/__manifest__.py | 6 +- addons/account_edi/data/cron.xml | 11 + addons/account_edi/models/__init__.py | 3 +- .../models/account_edi_document.py | 177 ++++++++++++ .../account_edi/models/account_edi_format.py | 170 +++++++---- addons/account_edi/models/account_journal.py | 47 ++- addons/account_edi/models/account_move.py | 273 +++++++++++++++++- addons/account_edi/models/account_payment.py | 11 + addons/account_edi/models/ir_attachment.py | 10 - addons/account_edi/models/mail_template.py | 22 +- .../account_edi/security/ir.model.access.csv | 3 + addons/account_edi/tests/__init__.py | 5 + addons/account_edi/tests/common.py | 82 ++++++ addons/account_edi/tests/test_edi.py | 46 +++ .../views/account_edi_document_views.xml | 15 + .../views/account_journal_views.xml | 7 +- .../account_edi/views/account_move_views.xml | 48 +++ .../views/account_payment_views.xml | 40 +++ .../models/account_edi_format.py | 38 ++- .../test_file/test_facturx.pdf | Bin 0 -> 38295 bytes .../test_file/test_facturx.xml | 148 ++++++++++ .../account_edi_facturx/tests/test_facturx.py | 156 +++++----- .../l10n_be_edi/models/account_edi_format.py | 37 ++- addons/l10n_be_edi/tests/test_ubl.py | 37 +-- addons/l10n_it_edi/data/account_edi_data.xml | 1 - .../l10n_it_edi/models/account_edi_format.py | 11 + 40 files changed, 1307 insertions(+), 310 deletions(-) delete mode 100644 addons/account/tests/account_test_xml.py create mode 100644 addons/account_edi/data/cron.xml create mode 100644 addons/account_edi/models/account_edi_document.py create mode 100644 addons/account_edi/models/account_payment.py delete mode 100644 addons/account_edi/models/ir_attachment.py create mode 100644 addons/account_edi/tests/__init__.py create mode 100644 addons/account_edi/tests/common.py create mode 100644 addons/account_edi/tests/test_edi.py create mode 100644 addons/account_edi/views/account_edi_document_views.xml create mode 100644 addons/account_edi/views/account_move_views.xml create mode 100644 addons/account_edi/views/account_payment_views.xml create mode 100644 addons/account_edi_facturx/test_file/test_facturx.pdf create mode 100644 addons/account_edi_facturx/test_file/test_facturx.xml 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 @@