diff --git a/sale_section_and_note_revamp/README.rst b/sale_section_and_note_revamp/README.rst new file mode 100644 index 000000000..be6254b7c --- /dev/null +++ b/sale_section_and_note_revamp/README.rst @@ -0,0 +1,77 @@ +============================ +Sale Section and Note Revamp +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:93a18de79802b53ca002dd70a96fbae9f23b29c5ede4e2a1c0c2722b83e76095 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--reporting-lightgray.png?logo=github + :target: https://github.com/OCA/sale-reporting/tree/15.0/sale_section_and_note_revamp + :alt: OCA/sale-reporting +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/sale-reporting-15-0/sale-reporting-15-0-sale_section_and_note_revamp + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/sale-reporting&target_branch=15.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides high level method helpers to ease sale sections and notes +management and use elsewhere. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Stéphane Mangin + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/sale-reporting `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_section_and_note_revamp/__init__.py b/sale_section_and_note_revamp/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/sale_section_and_note_revamp/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_section_and_note_revamp/__manifest__.py b/sale_section_and_note_revamp/__manifest__.py new file mode 100644 index 000000000..322956edb --- /dev/null +++ b/sale_section_and_note_revamp/__manifest__.py @@ -0,0 +1,12 @@ +{ + "name": "Sale Section and Note Revamp", + "version": "15.0.1.0.0", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Sales", + "depends": ["sale"], + "website": "https://github.com/OCA/sale-reporting", + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/sale_section_and_note_revamp/models/__init__.py b/sale_section_and_note_revamp/models/__init__.py new file mode 100644 index 000000000..69648bfe4 --- /dev/null +++ b/sale_section_and_note_revamp/models/__init__.py @@ -0,0 +1,3 @@ +from . import display_line_mixin +from . import sale_order_line +from . import sale_order diff --git a/sale_section_and_note_revamp/models/display_line_mixin.py b/sale_section_and_note_revamp/models/display_line_mixin.py new file mode 100644 index 000000000..5e84f8ef8 --- /dev/null +++ b/sale_section_and_note_revamp/models/display_line_mixin.py @@ -0,0 +1,177 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class DisplayLineMixin(models.AbstractModel): + _name = "display.line.mixin" + _description = """This model intends to ease the propagation and generation +of sections and notes to any model in relation with a sale order line""" + _order = "sequence ASC, id ASC" + + sequence = fields.Integer() + previous_line_id = fields.Many2one("sale.order.line") + next_line_id = fields.Many2one("sale.order.line") + display_type = fields.Char(store=False) + + def get_section_or_note_class(self): + """Return the class names depending of the display type + + Classes come from sale sections scss configuration + """ + if self.is_section(): + return "bg-200 font-weight-bold o_line_section" + elif self.is_note(): + return "font-italic o_line_note" + return "" + + def is_section(self): + """Quick method helper""" + self.ensure_one() + return self.display_type == "line_section" + + def is_note(self): + """Quick method helper""" + self.ensure_one() + return self.display_type == "line_note" + + def is_section_or_note(self): + """Quick method helper""" + self.ensure_one() + return self.is_section() or self.is_note() + + def has_section(self): + """Quick method helper""" + return bool(self.get_section()) + + def has_note(self): + """Quick method helper""" + return bool(self.get_note()) + + def get_section(self): + """Returns the section of the current line""" + if not self.is_section(): + previous_record = self.previous_line_id + while previous_record: + if previous_record.is_section(): + return previous_record + previous_record = previous_record.previous_line_id + + def get_note(self): + """Return the note positioned after an order line even if a section""" + if not self.is_note(): + if self.next_line_id.is_note(): + return self.next_line_id + + def get_section_subtotal(self, fields=None): + """Return the sum of each individual provided fields + :param fields: a list of fields to sum + :returns: a dict as {field: sum values} + """ + self.ensure_one() + if self.is_section(): + if not fields: + fields = [] + return { + field: sum(self._get_section_lines(with_notes=False).mapped(field)) + for field in fields + } + + def _get_section_lines(self, with_notes=True): + """Get all order lines in a section. + :param with_notes: Include note lines + """ + self.ensure_one() + if self.is_section(): + result = self.env[self._name] + next_record = self.next_line_id + while next_record: + if next_record.is_section(): + break + result |= next_record + next_record = next_record.next_line_id + notes = result.filtered(lambda r: r.is_note()) + return result if with_notes else result - notes + + def add_line(self, line, before=False): + """Add a line after or before the current line""" + self.ensure_one() + # Tie together previous line and next line of the line given in argument + if line.previous_line_id: + line.previous_line_id.next_line_id = line.next_line_id + if line.next_line_id: + line.next_line_id.previous_line_id = line.previous_line_id + + # Insersion of the line given inbetween current line and previous/next line + if before: + if self.previous_line_id: + self.previous_line_id.next_line_id = line + line.previous_line_id = self.previous_line_id + self.previous_line_id = line + line.next_line_id = self + else: + if self.next_line_id: + self.next_line_id.previous_line_id = line + line.next_line_id = self.next_line_id + self.next_line_id = line + line.previous_line_id = self + + def prepare_section_or_note_values(self, order_line): + """This method is intended to be used to `convert` a display line to + the current model + + It is mainly used for display lines injection to delivery reports + + :param order_line: a sale.order.line record + """ + self.ensure_one() + raise NotImplementedError + + def inject_sections_and_notes(self): + """This method inject all related display lines to the right position + for the inheriting model + + See sale.order::calc_order_lines_dependencies for further explanations + """ + model_name = self._name + + def add_section_or_note(record, display_line): + values = record.prepare_section_or_note_values(display_line) + return self.new(values) + + result = self.env[model_name] + # Avoid repeating display lines over hierarchy + done_record = self.env["sale.order.line"] + total_records = len(self) + for index, record in enumerate(self.sorted("sequence")): + + # We parse all previous lines to retrieve every section or notes + previous_lines = self.env[model_name] + previous_record = record.previous_line_id + while previous_record: + if previous_record in done_record: + break + elif previous_record.is_section_or_note(): + previous_lines |= add_section_or_note(record, previous_record) + done_record |= previous_record + previous_record = previous_record.previous_line_id + # As we parsed backwards, we need to sort back the lines ^^ + if previous_lines: + result |= previous_lines.sorted("sequence") + + # Then of course we add our current line + result |= record + + # Manage last lines if sections or notes + if (index + 1) >= total_records and record.next_line_id: + next_record = record.next_line_id + while next_record: + if next_record in done_record: + break + elif next_record.is_section_or_note(): + result |= add_section_or_note(record, next_record) + done_record |= next_record + next_record = next_record.next_line_id + + return result diff --git a/sale_section_and_note_revamp/models/sale_order.py b/sale_section_and_note_revamp/models/sale_order.py new file mode 100644 index 000000000..2a694227b --- /dev/null +++ b/sale_section_and_note_revamp/models/sale_order.py @@ -0,0 +1,19 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def calc_order_lines_dependencies(self): + """Link order lines with their respective siblings""" + for order in self: + order.order_line.write({"previous_line_id": False, "next_line_id": False}) + previous_line = self.env["sale.order.line"] + for order_line in order.order_line.sorted("sequence"): + order_line.write({"previous_line_id": previous_line.id}) + if previous_line: + previous_line.write({"next_line_id": order_line.id}) + previous_line = order_line diff --git a/sale_section_and_note_revamp/models/sale_order_line.py b/sale_section_and_note_revamp/models/sale_order_line.py new file mode 100644 index 000000000..ebbb8dc21 --- /dev/null +++ b/sale_section_and_note_revamp/models/sale_order_line.py @@ -0,0 +1,21 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import api, models + + +class SaleOrderLine(models.Model): + _name = "sale.order.line" + _inherit = ["sale.order.line", "display.line.mixin"] + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + res.order_id.calc_order_lines_dependencies() + return res + + def write(self, vals): + res = super().write(vals) + if any(fname in vals for fname in ["order_id", "sequence"]): + self.order_id.calc_order_lines_dependencies() + return res diff --git a/sale_section_and_note_revamp/readme/CONTRIBUTORS.rst b/sale_section_and_note_revamp/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..6cfe4d7f8 --- /dev/null +++ b/sale_section_and_note_revamp/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Stéphane Mangin diff --git a/sale_section_and_note_revamp/readme/DESCRIPTION.rst b/sale_section_and_note_revamp/readme/DESCRIPTION.rst new file mode 100644 index 000000000..96a8f4547 --- /dev/null +++ b/sale_section_and_note_revamp/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module provides high level method helpers to ease sale sections and notes +management and use elsewhere. diff --git a/sale_section_and_note_revamp/static/description/index.html b/sale_section_and_note_revamp/static/description/index.html new file mode 100644 index 000000000..a89871ec6 --- /dev/null +++ b/sale_section_and_note_revamp/static/description/index.html @@ -0,0 +1,421 @@ + + + + + +Sale Section and Note Revamp + + + +
+

Sale Section and Note Revamp

+ + +

Beta License: AGPL-3 OCA/sale-reporting Translate me on Weblate Try me on Runboat

+

This module provides high level method helpers to ease sale sections and notes +management and use elsewhere.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/sale-reporting project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/sale_section_and_note_revamp/tests/__init__.py b/sale_section_and_note_revamp/tests/__init__.py new file mode 100644 index 000000000..af0465aea --- /dev/null +++ b/sale_section_and_note_revamp/tests/__init__.py @@ -0,0 +1 @@ +from . import test_display_line_mixin diff --git a/sale_section_and_note_revamp/tests/common.py b/sale_section_and_note_revamp/tests/common.py new file mode 100644 index 000000000..dc866d6b4 --- /dev/null +++ b/sale_section_and_note_revamp/tests/common.py @@ -0,0 +1,106 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + + +from odoo.addons.sale.tests.common import TestSaleCommon + + +class TestDisplayLineMixinCommon(TestSaleCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + SaleOrder = cls.env["sale.order"] + SaleOrderLine = cls.env["sale.order.line"] + + # create a generic Sale Order with all classical products and empty pricelist + cls.sale_order = SaleOrder.create( + { + "partner_id": cls.partner_a.id, + "partner_invoice_id": cls.partner_a.id, + "partner_shipping_id": cls.partner_a.id, + "pricelist_id": cls.company_data["default_pricelist"].id, + } + ) + + cls.sol_section_1 = SaleOrderLine.create( + { + "sequence": 1, + "order_id": cls.sale_order.id, + "display_type": "line_section", + "name": "Sample Section", + } + ) + cls.sol_product_order = SaleOrderLine.create( + { + "sequence": 2, + "name": cls.company_data["product_order_no"].name, + "product_id": cls.company_data["product_order_no"].id, + "product_uom_qty": 2, + "product_uom": cls.company_data["product_order_no"].uom_id.id, + "price_unit": cls.company_data["product_order_no"].list_price, + "order_id": cls.sale_order.id, + "tax_id": False, + } + ) + cls.sol_serv_deliver = SaleOrderLine.create( + { + "sequence": 3, + "name": cls.company_data["product_service_delivery"].name, + "product_id": cls.company_data["product_service_delivery"].id, + "product_uom_qty": 2, + "product_uom": cls.company_data["product_service_delivery"].uom_id.id, + "price_unit": cls.company_data["product_service_delivery"].list_price, + "order_id": cls.sale_order.id, + "tax_id": False, + } + ) + cls.sol_note_1 = SaleOrderLine.create( + { + "sequence": 4, + "order_id": cls.sale_order.id, + "display_type": "line_note", + "name": "Sample Note 1", + } + ) + cls.sol_serv_order = SaleOrderLine.create( + { + "sequence": 5, + "name": cls.company_data["product_service_order"].name, + "product_id": cls.company_data["product_service_order"].id, + "product_uom_qty": 2, + "product_uom": cls.company_data["product_service_order"].uom_id.id, + "price_unit": cls.company_data["product_service_order"].list_price, + "order_id": cls.sale_order.id, + "tax_id": False, + } + ) + cls.sol_section_2 = SaleOrderLine.create( + { + "sequence": 6, + "order_id": cls.sale_order.id, + "display_type": "line_section", + "name": "Sample Section 2", + } + ) + cls.sol_product_deliver = SaleOrderLine.create( + { + "sequence": 7, + "name": cls.company_data["product_delivery_no"].name, + "product_id": cls.company_data["product_delivery_no"].id, + "product_uom_qty": 2, + "product_uom": cls.company_data["product_delivery_no"].uom_id.id, + "price_unit": cls.company_data["product_delivery_no"].list_price, + "order_id": cls.sale_order.id, + "tax_id": False, + } + ) + cls.sol_note_2 = SaleOrderLine.create( + { + "sequence": 8, + "order_id": cls.sale_order.id, + "display_type": "line_note", + "name": "Sample Note 2", + } + ) diff --git a/sale_section_and_note_revamp/tests/test_display_line_mixin.py b/sale_section_and_note_revamp/tests/test_display_line_mixin.py new file mode 100644 index 000000000..1418e2e33 --- /dev/null +++ b/sale_section_and_note_revamp/tests/test_display_line_mixin.py @@ -0,0 +1,115 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.tests import tagged + +from .common import TestDisplayLineMixinCommon + + +@tagged("post_install", "-at_install") +class TestDisplayLineMixin(TestDisplayLineMixinCommon): + def test_01_is_section(self): + self.assertTrue(self.sol_section_1.is_section()) + self.assertFalse(self.sol_product_order.is_section()) + + def test_02_is_note(self): + self.assertTrue(self.sol_note_1.is_note()) + self.assertFalse(self.sol_serv_deliver.is_note()) + + def test_03_get_section(self): + section = self.sol_product_order.get_section() + self.assertEqual(section, self.sol_section_1) + + # Taking the third line to ensure we execute twice the while loop + # We are expecting the first SOL to be a section + third_line = self.sale_order.order_line[2] + self.assertEqual(third_line.get_section(), self.sol_section_1) + + def test_04_get_note(self): + note = self.sol_serv_deliver.get_note() + self.assertEqual(note, self.sol_note_1) + + def test_05_get_section_subtotal(self): + section_subtotal = self.sol_section_1.get_section_subtotal( + fields=["price_total"] + ) + expected_subtotal = sum( + [ + self.sol_product_order.price_total, + self.sol_serv_deliver.price_total, + self.sol_serv_order.price_total, + ] + ) + # Check if the calculated subtotal matches the expected subtotal + self.assertEqual(section_subtotal["price_total"], expected_subtotal) + + def test_06_prepare_section_or_note_values(self): + with self.assertRaises(NotImplementedError): + self.sol_product_order.prepare_section_or_note_values(self.sol_section_1) + + def test_07_inject_sections_and_notes(self): + with self.assertRaises(NotImplementedError): + self.sol_product_order.inject_sections_and_notes() + + def _assert_previous_next_line(self, order): + sorted_lines = order.order_line.sorted() + for i, sol in enumerate(sorted_lines): + if i == 0: + self.assertFalse(sol.previous_line_id) + self.assertEqual(sol.next_line_id, sorted_lines[i + 1]) + elif i == len(order.order_line) - 1: + self.assertEqual(sol.previous_line_id, sorted_lines[i - 1]) + self.assertFalse(sol.next_line_id) + else: + self.assertEqual(sol.previous_line_id, sorted_lines[i - 1]) + self.assertEqual(sol.next_line_id, sorted_lines[i + 1]) + + def test_08_previous_next_line_calculation(self): + self._assert_previous_next_line(self.sale_order) + # Switch first and second lines + first_line = self.sale_order.order_line[0] + second_line = self.sale_order.order_line[1] + first_line_sequence = first_line.sequence + first_line.sequence = second_line.sequence + second_line.sequence = first_line_sequence + self._assert_previous_next_line(self.sale_order) + # Switch last and penultimate lines + last_line = self.sale_order.order_line[-1] + penultimate_line = self.sale_order.order_line[-2] + last_line_sequence = last_line.sequence + last_line_sequence = penultimate_line.sequence + penultimate_line.sequence = last_line_sequence + self._assert_previous_next_line(self.sale_order) + + def test_09_insert_new_line_after(self): + first_line = self.sale_order.order_line[0] + second_line = self.sale_order.order_line[1] + third_line = self.sale_order.order_line[2] + penultimate_line = self.sale_order.order_line[-2] + last_line = self.sale_order.order_line[-1] + self.assertEqual(first_line.next_line_id, second_line) + + # we move the second line after the penultimate line + penultimate_line.add_line(second_line) + self.assertEqual(penultimate_line.next_line_id, second_line) + self.assertEqual(last_line.previous_line_id, second_line) + + self.assertEqual(first_line.next_line_id, third_line) + self.assertEqual(third_line.previous_line_id, first_line) + + def test_10_insert_new_line_before(self): + first_line = self.sale_order.order_line[0] + second_line = self.sale_order.order_line[1] + penultimate_line = self.sale_order.order_line[-2] + last_line = self.sale_order.order_line[-1] + self.assertEqual(first_line.next_line_id, second_line) + + # we move the second line before the last line + last_line.add_line(second_line, before=True) + self.assertEqual(last_line.previous_line_id, second_line) + self.assertEqual(penultimate_line.next_line_id, second_line) + self.assertEqual(second_line.next_line_id, last_line) + self.assertEqual(second_line.previous_line_id, penultimate_line) + + def test_12_has_section(self): + self.assertTrue(self.sol_serv_deliver.has_section()) diff --git a/setup/sale_section_and_note_revamp/odoo/addons/sale_section_and_note_revamp b/setup/sale_section_and_note_revamp/odoo/addons/sale_section_and_note_revamp new file mode 120000 index 000000000..e4b8f0114 --- /dev/null +++ b/setup/sale_section_and_note_revamp/odoo/addons/sale_section_and_note_revamp @@ -0,0 +1 @@ +../../../../sale_section_and_note_revamp \ No newline at end of file diff --git a/setup/sale_section_and_note_revamp/setup.py b/setup/sale_section_and_note_revamp/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/sale_section_and_note_revamp/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)