From 73ebfe9c29772825c30cfaaa844f34dc83e8f502 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Mon, 29 Nov 2021 09:41:06 +0100 Subject: [PATCH 1/4] account_invoice_section_sale_order: Custom section name Refactor preparation of sections Refactor to allow different section grouping Add field to allow different possibilities of grouping invoice lines from sale order --- .../__manifest__.py | 5 ++ .../models/__init__.py | 4 + .../models/account_move.py | 40 +++++++++ .../models/res_company.py | 23 +++++ .../models/res_config_settings.py | 19 +++++ .../models/res_partner.py | 14 ++++ .../models/sale_order.py | 58 +++++++------ .../readme/CONFIGURATION.rst | 9 ++ .../readme/CONTRIBUTORS.rst | 4 +- .../readme/DESCRIPTION.rst | 3 + .../security/res_groups.xml | 9 ++ .../tests/test_invoice_group_by_sale_order.py | 84 ++++++++++++++++--- .../views/res_config_settings.xml | 44 ++++++++++ .../views/res_partner.xml | 22 +++++ 14 files changed, 303 insertions(+), 35 deletions(-) create mode 100644 account_invoice_section_sale_order/models/account_move.py create mode 100644 account_invoice_section_sale_order/models/res_company.py create mode 100644 account_invoice_section_sale_order/models/res_config_settings.py create mode 100644 account_invoice_section_sale_order/models/res_partner.py create mode 100644 account_invoice_section_sale_order/readme/CONFIGURATION.rst create mode 100644 account_invoice_section_sale_order/security/res_groups.xml create mode 100644 account_invoice_section_sale_order/views/res_config_settings.xml create mode 100644 account_invoice_section_sale_order/views/res_partner.xml diff --git a/account_invoice_section_sale_order/__manifest__.py b/account_invoice_section_sale_order/__manifest__.py index beb125bd37c..f466ac2af3c 100644 --- a/account_invoice_section_sale_order/__manifest__.py +++ b/account_invoice_section_sale_order/__manifest__.py @@ -10,4 +10,9 @@ "license": "AGPL-3", "category": "Accounting & Finance", "depends": ["account", "sale"], + "data": [ + "security/res_groups.xml", + "views/res_config_settings.xml", + "views/res_partner.xml", + ], } diff --git a/account_invoice_section_sale_order/models/__init__.py b/account_invoice_section_sale_order/models/__init__.py index 6aacb753131..3b0f67faa2c 100644 --- a/account_invoice_section_sale_order/models/__init__.py +++ b/account_invoice_section_sale_order/models/__init__.py @@ -1 +1,5 @@ +from . import account_move +from . import res_company +from . import res_config_settings +from . import res_partner from . import sale_order diff --git a/account_invoice_section_sale_order/models/account_move.py b/account_invoice_section_sale_order/models/account_move.py new file mode 100644 index 00000000000..80b54fc77c2 --- /dev/null +++ b/account_invoice_section_sale_order/models/account_move.py @@ -0,0 +1,40 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import _, api, models +from odoo.exceptions import UserError + + +class AccountMove(models.Model): + + _inherit = "account.move" + + def _get_ordered_invoice_lines(self): + """Sort invoice lines according to the section ordering""" + return self.invoice_line_ids.sorted( + key=self.env["account.move.line"]._get_section_ordering() + ) + + +class AccountMoveLine(models.Model): + + _inherit = "account.move.line" + + def _get_section_group(self): + """Return the section group to be used for a single invoice line""" + self.ensure_one() + return self.mapped(self._get_section_grouping()) + + def _get_section_grouping(self): + """Defines the grouping relation from the invoice lines to be used. + + Meant to be overriden, in order to allow custom grouping. + """ + invoice_section_grouping = self.company_id.invoice_section_grouping + if invoice_section_grouping == "sale_order": + return "sale_line_ids.order_id" + raise UserError(_("Unrecognized invoice_section_grouping")) + + @api.model + def _get_section_ordering(self): + """Function to sort invoice lines before grouping""" + return lambda r: r.mapped(r._get_section_grouping()) diff --git a/account_invoice_section_sale_order/models/res_company.py b/account_invoice_section_sale_order/models/res_company.py new file mode 100644 index 00000000000..12ceee74902 --- /dev/null +++ b/account_invoice_section_sale_order/models/res_company.py @@ -0,0 +1,23 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + invoice_section_name_scheme = fields.Char( + help="This is the name of the sections on invoices when generated from " + "sales orders. Keep empty to use default. You can use a python " + "expression with the 'object' (representing sale order) and 'time'" + " variables." + ) + + invoice_section_grouping = fields.Selection( + [ + ("sale_order", "Group by sale Order"), + ], + help="Defines object used to group invoice lines", + default="sale_order", + required=True, + ) diff --git a/account_invoice_section_sale_order/models/res_config_settings.py b/account_invoice_section_sale_order/models/res_config_settings.py new file mode 100644 index 00000000000..7b9c20adc72 --- /dev/null +++ b/account_invoice_section_sale_order/models/res_config_settings.py @@ -0,0 +1,19 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + + _inherit = "res.config.settings" + + invoice_section_name_scheme = fields.Char( + related="company_id.invoice_section_name_scheme", + readonly=False, + ) + + invoice_section_grouping = fields.Selection( + related="company_id.invoice_section_grouping", + readonly=False, + required=True, + ) diff --git a/account_invoice_section_sale_order/models/res_partner.py b/account_invoice_section_sale_order/models/res_partner.py new file mode 100644 index 00000000000..87f221578fe --- /dev/null +++ b/account_invoice_section_sale_order/models/res_partner.py @@ -0,0 +1,14 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + invoice_section_name_scheme = fields.Char( + help="This is the name of the sections on invoices when generated from " + "sales orders. Keep empty to use default. You can use a python " + "expression with the 'object' (representing sale order) and 'time'" + " variables." + ) diff --git a/account_invoice_section_sale_order/models/sale_order.py b/account_invoice_section_sale_order/models/sale_order.py index fdf55d0bc01..e5211c4a1a8 100644 --- a/account_invoice_section_sale_order/models/sale_order.py +++ b/account_invoice_section_sale_order/models/sale_order.py @@ -1,60 +1,70 @@ # Copyright 2020 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) +from collections import OrderedDict from odoo import models +from odoo.tools.safe_eval import safe_eval, time class SaleOrder(models.Model): _inherit = "sale.order" def _create_invoices(self, grouped=False, final=False, date=None): - """Add sections by sale order in the invoice line. + """Add sections by groups in the invoice line. - Order the invoicing lines by sale order and add lines section with - the sale order name. - Only do this for invoices targetting multiple sale order + Order the invoicing lines by groups and add lines section with + the group name. + Only do this for invoices targetting multiple groups """ invoice_ids = super()._create_invoices(grouped=grouped, final=final) for invoice in invoice_ids: - if len(invoice.line_ids.mapped("sale_line_ids.order_id.id")) == 1: + if ( + len(invoice.line_ids.mapped(invoice.line_ids._get_section_grouping())) + == 1 + ): continue - so = None sequence = 10 + move_lines = invoice._get_ordered_invoice_lines() + # Group move lines according to their sale order + section_grouping_matrix = OrderedDict() + for move_line in move_lines: + group = move_line._get_section_group() + section_grouping_matrix.setdefault(group, []).append(move_line.id) + # Prepare section lines for each group section_lines = [] - lines = self._get_ordered_invoice_lines(invoice) - for line in lines: - if line.sale_line_ids.order_id and so != line.sale_line_ids.order_id: - so = line.sale_line_ids.order_id + for group, move_line_ids in section_grouping_matrix.items(): + if group: section_lines.append( ( 0, 0, { - "name": so._get_saleorder_section_name(), + "name": group._get_invoice_section_name(), "display_type": "line_section", "sequence": sequence, }, ) ) sequence += 10 - if line.display_type == "line_section": - # add extra indent for existing SO Sections - line.name = f"- {line.name}" - line.sequence = sequence - sequence += 10 + for move_line in self.env["account.move.line"].browse(move_line_ids): + if move_line.display_type == "line_section": + # add extra indent for existing SO Sections + move_line.name = f"- {move_line.name}" + move_line.sequence = sequence + sequence += 10 invoice.line_ids = section_lines - return invoice_ids - def _get_ordered_invoice_lines(self, invoice): - return invoice.line_ids.sorted( - key=lambda r: r.sale_line_ids.order_id.id - ).filtered(lambda r: not r.exclude_from_invoice_tab) - - def _get_saleorder_section_name(self): + def _get_invoice_section_name(self): """Returns the text for the section name.""" self.ensure_one() - if self.client_order_ref: + naming_scheme = ( + self.partner_invoice_id.invoice_section_name_scheme + or self.company_id.invoice_section_name_scheme + ) + if naming_scheme: + return safe_eval(naming_scheme, {"object": self, "time": time}) + elif self.client_order_ref: return "{} - {}".format(self.name, self.client_order_ref or "") else: return self.name diff --git a/account_invoice_section_sale_order/readme/CONFIGURATION.rst b/account_invoice_section_sale_order/readme/CONFIGURATION.rst new file mode 100644 index 00000000000..9fc4d5d25ec --- /dev/null +++ b/account_invoice_section_sale_order/readme/CONFIGURATION.rst @@ -0,0 +1,9 @@ +To allow customization of the name of the section, user should be part of group +`Allow customization of invoice section name from sale order`. + +A naming scheme can be defined per company on the configuration page in the +`Customer Invoices` section, or per partner in the accounting page, using +python expression. + +The object used for the grouping can be customized by installing extra module +(e.g. `account_invoice_section_picking`). diff --git a/account_invoice_section_sale_order/readme/CONTRIBUTORS.rst b/account_invoice_section_sale_order/readme/CONTRIBUTORS.rst index 0860086f795..0e30819b01e 100644 --- a/account_invoice_section_sale_order/readme/CONTRIBUTORS.rst +++ b/account_invoice_section_sale_order/readme/CONTRIBUTORS.rst @@ -1,5 +1,7 @@ * `Camptocamp `_ * Thierry Ducrest + * Akim Juillerat * Hiep Nguyen Hoang - * Jeroen Evens + +* Jeroen Evens diff --git a/account_invoice_section_sale_order/readme/DESCRIPTION.rst b/account_invoice_section_sale_order/readme/DESCRIPTION.rst index 7c7604ca5b5..73a5ffaef17 100644 --- a/account_invoice_section_sale_order/readme/DESCRIPTION.rst +++ b/account_invoice_section_sale_order/readme/DESCRIPTION.rst @@ -4,4 +4,7 @@ to know which invoice line belongs to which sale order. This module helps by grouping invoicing lines into sections with the name of the targeted sale order. + +The name of the section can be customized by company and partner. + This is only done when an invoice targets multiple sale order. diff --git a/account_invoice_section_sale_order/security/res_groups.xml b/account_invoice_section_sale_order/security/res_groups.xml new file mode 100644 index 00000000000..e15385b13a7 --- /dev/null +++ b/account_invoice_section_sale_order/security/res_groups.xml @@ -0,0 +1,9 @@ + + + + Allow customization of invoice section name from sale order + + + diff --git a/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py b/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py index 1275c323d59..b2765f053c6 100644 --- a/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py +++ b/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py @@ -1,8 +1,15 @@ # Copyright 2020 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) +import mock +from odoo.exceptions import UserError from odoo.tests.common import TransactionCase +SECTION_GROUPING_FUNCTION = "odoo.addons.account_invoice_section_sale_order.models.account_move.AccountMoveLine._get_section_grouping" # noqa +SECTION_NAME_FUNCTION = ( + "odoo.addons.base.models.res_users.Users._get_invoice_section_name" +) + class TestInvoiceGroupBySaleOrder(TransactionCase): @classmethod @@ -10,7 +17,9 @@ def setUpClass(cls): super().setUpClass() cls.partner_1 = cls.env.ref("base.res_partner_1") cls.product_1 = cls.env.ref("product.product_product_1") + cls.product_2 = cls.env.ref("product.product_product_2") cls.product_1.invoice_policy = "order" + cls.product_2.invoice_policy = "order" cls.order1_p1 = cls.env["sale.order"].create( { "partner_id": cls.partner_1.id, @@ -34,7 +43,7 @@ def setUpClass(cls): 0, { "name": "order 1 line 2", - "product_id": cls.product_1.id, + "product_id": cls.product_2.id, "price_unit": 20, "product_uom_qty": 1, "product_uom": cls.product_1.uom_id.id, @@ -66,7 +75,7 @@ def setUpClass(cls): 0, { "name": "order 2 line 2", - "product_id": cls.product_1.id, + "product_id": cls.product_2.id, "price_unit": 20, "product_uom_qty": 1, "product_uom": cls.product_1.uom_id.id, @@ -80,12 +89,15 @@ def setUpClass(cls): def test_create_invoice(self): """Check invoice is generated with sale order sections.""" result = { - 0: "".join([self.order1_p1.name, " - ", self.order1_p1.client_order_ref]), - 1: "order 1 line 1", - 2: "order 1 line 2", - 3: self.order2_p1.name, - 4: "order 2 line 1", - 5: "order 2 line 2", + 10: ( + "".join([self.order1_p1.name, " - ", self.order1_p1.client_order_ref]), + "line_section", + ), + 20: ("order 1 line 1", False), + 30: ("order 1 line 2", False), + 40: (self.order2_p1.name, "line_section"), + 50: ("order 2 line 1", False), + 60: ("order 2 line 2", False), } invoice_ids = (self.order1_p1 + self.order2_p1)._create_invoices() lines = ( @@ -93,8 +105,9 @@ def test_create_invoice(self): .line_ids.sorted("sequence") .filtered(lambda r: not r.exclude_from_invoice_tab) ) - for idx, line in enumerate(lines): - self.assertEqual(line.name, result[idx]) + for line in lines: + self.assertEqual(line.name, result[line.sequence][0]) + self.assertEqual(line.display_type, result[line.sequence][1]) def test_create_invoice_no_section(self): """Check invoice for only one sale order @@ -107,3 +120,54 @@ def test_create_invoice_no_section(self): lambda r: r.display_type == "line_section" ) self.assertEqual(len(line_sections), 0) + + def test_unknown_invoice_section_grouping_value(self): + """Check an error is raised when invoice_section_grouping value is + unknown + """ + mock_company_section_grouping = mock.patch.object( + type(self.env.company), + "invoice_section_grouping", + new_callable=mock.PropertyMock, + ) + with mock_company_section_grouping as mocked_company_section_grouping: + mocked_company_section_grouping.return_value = "unknown" + with self.assertRaises(UserError): + (self.order1_p1 + self.order2_p1)._create_invoices() + + def test_custom_grouping_by_sale_order_user(self): + """Check custom grouping by sale order user. + + By mocking account.move.line_get_section_grouping and creating + res.users.get_invoice_section_name, this test ensures custom grouping + is possible by redefining these functions""" + demo_user = self.env.ref("base.user_demo") + admin_user = self.env.ref("base.partner_admin") + orders = self.order1_p1 + self.order2_p1 + orders.write({"user_id": admin_user.id}) + sale_order_3 = self.order1_p1.copy({"user_id": demo_user.id}) + sale_order_3.order_line[0].name = "order 3 line 1" + sale_order_3.order_line[1].name = "order 3 line 2" + sale_order_3.action_confirm() + + with mock.patch( + SECTION_GROUPING_FUNCTION + ) as mocked_get_section_grouping, mock.patch( + SECTION_NAME_FUNCTION, create=True + ) as mocked_get_invoice_section_name: + mocked_get_section_grouping.return_value = "sale_line_ids.order_id.user_id" + mocked_get_invoice_section_name.return_value = "Mocked value from ResUsers" + invoice = (orders + sale_order_3)._create_invoices() + result = { + 10: ("Mocked value from ResUsers", "line_section"), + 20: ("order 1 line 1", False), + 30: ("order 1 line 2", False), + 40: ("order 2 line 1", False), + 50: ("order 2 line 2", False), + 60: ("Mocked value from ResUsers", "line_section"), + 70: ("order 3 line 1", False), + 80: ("order 3 line 2", False), + } + for line in invoice.invoice_line_ids.sorted("sequence"): + self.assertEqual(line.name, result[line.sequence][0]) + self.assertEqual(line.display_type, result[line.sequence][1]) diff --git a/account_invoice_section_sale_order/views/res_config_settings.xml b/account_invoice_section_sale_order/views/res_config_settings.xml new file mode 100644 index 00000000000..6a6ccb6f5d5 --- /dev/null +++ b/account_invoice_section_sale_order/views/res_config_settings.xml @@ -0,0 +1,44 @@ + + + + res.config.settings.view.form.inherit.account + res.config.settings + + + + +
+
+
+ Section names +
+ Customize section names when invoicing from sale orders +
+
+
+
+
+
+
+ + + + diff --git a/account_invoice_section_sale_order/views/res_partner.xml b/account_invoice_section_sale_order/views/res_partner.xml new file mode 100644 index 00000000000..9b3ce53c69d --- /dev/null +++ b/account_invoice_section_sale_order/views/res_partner.xml @@ -0,0 +1,22 @@ + + + + res.partner.property.form.inherit + res.partner + + + + + + + + + + + From afdfb6d76d7a7bb483739de18006781cf5adc05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Tue, 15 Mar 2022 22:41:07 +0100 Subject: [PATCH 2/4] [FIX] fix passing default_journal_id when invoicing --- account_invoice_section_sale_order/models/sale_order.py | 4 ++++ .../tests/test_invoice_group_by_sale_order.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/account_invoice_section_sale_order/models/sale_order.py b/account_invoice_section_sale_order/models/sale_order.py index e5211c4a1a8..d7a868f4076 100644 --- a/account_invoice_section_sale_order/models/sale_order.py +++ b/account_invoice_section_sale_order/models/sale_order.py @@ -42,6 +42,10 @@ def _create_invoices(self, grouped=False, final=False, date=None): "name": group._get_invoice_section_name(), "display_type": "line_section", "sequence": sequence, + # see test: test_create_invoice_with_default_journal + # forcing the account_id is needed to avoid + # incorrect default value + "account_id": False, }, ) ) diff --git a/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py b/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py index b2765f053c6..45f062ce3f7 100644 --- a/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py +++ b/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py @@ -109,6 +109,13 @@ def test_create_invoice(self): self.assertEqual(line.name, result[line.sequence][0]) self.assertEqual(line.display_type, result[line.sequence][1]) + def test_create_invoice_with_default_journal(self): + """Using a specific journal for the invoice should not be broken""" + journal = self.env["account.journal"].search([("type", "=", "sale")], limit=1) + (self.order1_p1 + self.order2_p1).with_context( + default_journal_id=journal.id + )._create_invoices() + def test_create_invoice_no_section(self): """Check invoice for only one sale order From b55e35ca816b72cea90f7b7a9b749f8927c52c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Mon, 11 Apr 2022 17:44:12 +0200 Subject: [PATCH 3/4] [FIX] account_invoice_section_sale: fix invoice total when using currency --- .../models/sale_order.py | 5 +++++ .../tests/test_invoice_group_by_sale_order.py | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/account_invoice_section_sale_order/models/sale_order.py b/account_invoice_section_sale_order/models/sale_order.py index d7a868f4076..287c3af50d4 100644 --- a/account_invoice_section_sale_order/models/sale_order.py +++ b/account_invoice_section_sale_order/models/sale_order.py @@ -46,6 +46,11 @@ def _create_invoices(self, grouped=False, final=False, date=None): # forcing the account_id is needed to avoid # incorrect default value "account_id": False, + # see test: test_create_invoice_with_currency + # if the currency is not set with the right value + # the total amount will be wrong + # because all line do not have the same currency + "currency_id": invoice.currency_id.id, }, ) ) diff --git a/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py b/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py index 45f062ce3f7..500a10f856e 100644 --- a/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py +++ b/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py @@ -109,6 +109,17 @@ def test_create_invoice(self): self.assertEqual(line.name, result[line.sequence][0]) self.assertEqual(line.display_type, result[line.sequence][1]) + def test_create_invoice_with_currency(self): + """Check invoice is generated with a correct total amount""" + eur = self.env.ref("base.EUR") + pricelist = self.env["product.pricelist"].create( + {"name": "Europe pricelist", "currency_id": eur.id} + ) + orders = self.order1_p1 | self.order2_p1 + orders.write({"pricelist_id": pricelist.id}) + invoices = orders._create_invoices() + self.assertEqual(invoices.amount_total, 80) + def test_create_invoice_with_default_journal(self): """Using a specific journal for the invoice should not be broken""" journal = self.env["account.journal"].search([("type", "=", "sale")], limit=1) From ba621b14709f15515799c3f659a677c99aec9168 Mon Sep 17 00:00:00 2001 From: "Pedro M. Baeza" Date: Fri, 24 Feb 2023 20:11:47 +0100 Subject: [PATCH 4/4] [FIX] account_invoice_section_sale_order: Avoid problems in integration tests In OCA repo, now we have this error: Traceback (most recent call last): File "/__w/account-invoicing/account-invoicing/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py", line 109, in test_create_invoice self.assertEqual(line.name, result[line.sequence][0]) KeyError: 1 --- .../tests/test_invoice_group_by_sale_order.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py b/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py index 500a10f856e..2e3bf67c7c8 100644 --- a/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py +++ b/account_invoice_section_sale_order/tests/test_invoice_group_by_sale_order.py @@ -106,6 +106,8 @@ def test_create_invoice(self): .filtered(lambda r: not r.exclude_from_invoice_tab) ) for line in lines: + if line.sequence not in result: + continue self.assertEqual(line.name, result[line.sequence][0]) self.assertEqual(line.display_type, result[line.sequence][1]) @@ -187,5 +189,7 @@ def test_custom_grouping_by_sale_order_user(self): 80: ("order 3 line 2", False), } for line in invoice.invoice_line_ids.sorted("sequence"): + if line.sequence not in result: + continue self.assertEqual(line.name, result[line.sequence][0]) self.assertEqual(line.display_type, result[line.sequence][1])