From d35c246db4d6a1ddbe44193be9d62240114323df 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 | 38 ++++++++ .../models/res_company.py | 23 +++++ .../models/res_config_settings.py | 18 ++++ .../models/res_partner.py | 14 +++ .../models/sale_order.py | 53 +++++++---- .../readme/CONFIGURATION.rst | 9 ++ .../security/res_groups.xml | 9 ++ .../tests/test_invoice_group_by_sale_order.py | 90 ++++++++++++++++--- .../views/res_config_settings.xml | 35 ++++++++ .../views/res_partner.xml | 19 ++++ 12 files changed, 286 insertions(+), 31 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 b12606b6d0c..be24b148663 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..1c2c54ad712 --- /dev/null +++ b/account_invoice_section_sale_order/models/account_move.py @@ -0,0 +1,38 @@ +# 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..6753fbf48a4 --- /dev/null +++ b/account_invoice_section_sale_order/models/res_config_settings.py @@ -0,0 +1,18 @@ +# 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 33afb36ffeb..f82390f3c4c 100644 --- a/account_invoice_section_sale_order/models/sale_order.py +++ b/account_invoice_section_sale_order/models/sale_order.py @@ -1,49 +1,58 @@ # 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, date=date) 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): @@ -51,10 +60,16 @@ def _get_ordered_invoice_lines(self, invoice): key=lambda r: r.sale_line_ids.order_id.id ) - 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/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 9a791bdf0f7..b6be308a7b8 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) +from unittest 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, @@ -82,7 +91,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, @@ -96,19 +105,23 @@ 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 section 1", - 5: "order 2 line 1", - 6: "- order 2 section 2", - 7: "order 2 line 2", + 10: ( + "".join([self.order1_p1.name, " - ", self.order1_p1.client_order_ref]), + "line_section", + ), + 20: ("order 1 line 1", "product"), + 30: ("order 1 line 2", "product"), + 40: (self.order2_p1.name, "line_section"), + 50: ("- order 2 section 1", "line_section"), + 60: ("order 2 line 1", "product"), + 70: ("- order 2 section 2", "line_section"), + 80: ("order 2 line 2", "product"), } invoice_ids = (self.order1_p1 + self.order2_p1)._create_invoices() lines = invoice_ids[0].invoice_line_ids.sorted("sequence") - 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 @@ -121,3 +134,56 @@ 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", "product"), + 30: ("order 1 line 2", "product"), + 40: ("- order 2 section 1", "line_section"), + 50: ("order 2 line 1", "product"), + 60: ("- order 2 section 2", "line_section"), + 70: ("order 2 line 2", "product"), + 80: ("Mocked value from ResUsers", "line_section"), + 90: ("order 3 line 1", "product"), + 100: ("order 3 line 2", "product"), + } + 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..9d8f731cb26 --- /dev/null +++ b/account_invoice_section_sale_order/views/res_config_settings.xml @@ -0,0 +1,35 @@ + + + + res.config.settings.view.form.inherit.account + res.config.settings + + + + + +
+
+
+
+
+
+
+
+
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..b190f6040fb --- /dev/null +++ b/account_invoice_section_sale_order/views/res_partner.xml @@ -0,0 +1,19 @@ + + + + res.partner.property.form.inherit + res.partner + + + + + + + + + + From 03200ed23c6b8fe1b74a9ffd653c9822a387ac94 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 f82390f3c4c..8a12bb1c137 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 b6be308a7b8..919c71ffc3a 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 @@ -123,6 +123,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 b999748c12f6c422ad129c8ac82c4426c4638569 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 | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/account_invoice_section_sale_order/models/sale_order.py b/account_invoice_section_sale_order/models/sale_order.py index 8a12bb1c137..b6013f0771d 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 919c71ffc3a..4770c9bdc44 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 @@ -20,11 +20,16 @@ def setUpClass(cls): cls.product_2 = cls.env.ref("product.product_product_2") cls.product_1.invoice_policy = "order" cls.product_2.invoice_policy = "order" + eur = cls.env.ref("base.EUR") + cls.pricelist = cls.env["product.pricelist"].create( + {"name": "Europe pricelist", "currency_id": eur.id} + ) cls.order1_p1 = cls.env["sale.order"].create( { "partner_id": cls.partner_1.id, "partner_shipping_id": cls.partner_1.id, "partner_invoice_id": cls.partner_1.id, + "pricelist_id": cls.pricelist.id, "client_order_ref": "ref123", "order_line": [ ( @@ -58,6 +63,7 @@ def setUpClass(cls): "partner_id": cls.partner_1.id, "partner_shipping_id": cls.partner_1.id, "partner_invoice_id": cls.partner_1.id, + "pricelist_id": cls.pricelist.id, "order_line": [ ( 0, @@ -123,6 +129,12 @@ 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""" + orders = self.order1_p1 | self.order2_p1 + 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 8cd1abd4be79fa4adec276c0eeff42e2b0377588 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 4770c9bdc44..872d5a22337 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 @@ -126,6 +126,8 @@ def test_create_invoice(self): invoice_ids = (self.order1_p1 + self.order2_p1)._create_invoices() lines = invoice_ids[0].invoice_line_ids.sorted("sequence") 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]) @@ -204,5 +206,7 @@ def test_custom_grouping_by_sale_order_user(self): 100: ("order 3 line 2", "product"), } 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])