diff --git a/sale_fixed_discount/models/sale_order_line.py b/sale_fixed_discount/models/sale_order_line.py index 4692a5f6def..a66935852fa 100644 --- a/sale_fixed_discount/models/sale_order_line.py +++ b/sale_fixed_discount/models/sale_order_line.py @@ -83,7 +83,9 @@ def _compute_discount(self): lines_with_discount_fixed = self.filtered(lambda sol: sol.discount_fixed) for line in lines_with_discount_fixed: line.discount = line._get_discount_from_fixed_discount() - return super(SaleOrderLine, self - lines_with_discount_fixed) + return super( + SaleOrderLine, self - lines_with_discount_fixed + )._compute_discount() def _get_discount_from_fixed_discount(self): """Calculate the discount percentage from the fixed discount amount.""" diff --git a/sale_order_general_discount/models/sale_order_line.py b/sale_order_general_discount/models/sale_order_line.py index 6c1d738e087..940e6078c83 100644 --- a/sale_order_general_discount/models/sale_order_line.py +++ b/sale_order_general_discount/models/sale_order_line.py @@ -1,6 +1,7 @@ # Copyright 2018 Tecnativa - Sergio Teruel # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo import api, fields, models +from odoo.tools import config class SaleOrderLine(models.Model): @@ -15,6 +16,12 @@ class SaleOrderLine(models.Model): @api.depends("order_id", "order_id.general_discount") def _compute_discount(self): res = super()._compute_discount() + test_condition = not config["test_enable"] or ( + config["test_enable"] + and self.env.context.get("test_sale_order_general_discount") + ) + if not test_condition: + return res for line in self: # We check the value of general_discount on origin too to cover # the case where a discount was set to a value != 0 and then diff --git a/sale_order_general_discount/tests/test_sale_order_general_discount.py b/sale_order_general_discount/tests/test_sale_order_general_discount.py index a9f329d8d25..62e85b56eef 100644 --- a/sale_order_general_discount/tests/test_sale_order_general_discount.py +++ b/sale_order_general_discount/tests/test_sale_order_general_discount.py @@ -9,6 +9,9 @@ class TestSaleOrderLineInput(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() + cls.env = cls.env( + context=dict(cls.env.context, test_sale_order_general_discount=True) + ) cls.partner = cls.env["res.partner"].create( {"name": "Test", "sale_discount": 10.0} ) diff --git a/sale_pricelist_global_rule/README.rst b/sale_pricelist_global_rule/README.rst new file mode 100644 index 00000000000..b269d3fc5e0 --- /dev/null +++ b/sale_pricelist_global_rule/README.rst @@ -0,0 +1,127 @@ +========================== +Sale pricelist global rule +========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b93829a100ce8864f9f399ba2bf2c998544d9a7b00d3555405045eaebe3f4cd4 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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--workflow-lightgray.png?logo=github + :target: https://github.com/OCA/sale-workflow/tree/17.0/sale_pricelist_global_rule + :alt: OCA/sale-workflow +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/sale-workflow-17-0/sale-workflow-17-0-sale_pricelist_global_rule + :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-workflow&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows configured pricelists to be applied to a sales order +by considering cumulative quantities across all lines. + +**Global by Product Template** + +If a pricelist rule has a min_quantity = 15, and a sales order contains: + +- Line 1: Variant 1, quantity = 8 +- Line 2: Variant 2, quantity = 8 + +**Global by Product Category** + +Similarly, if a pricelist rule has a min_quantity = 20 for products +within a category, and a sales order includes: + +- Line 1: Product 1, quantity = 10 +- Line 2: Product 2, quantity = 10 + +In standard Odoo, pricelist rules would not apply since no single line +meets the minimum quantity. With this module, however, cumulative +quantities across lines allow the pricelist rule to apply, as they meet +the minimum threshold (16 in the product template example and 20 in the +product category example). + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +- Go to Sales -> Products -> Pricelist. +- Create a new Pricelist and add at least one line with the Apply On + option set to Global - Product template or Global - Product category +- Choose the specific product template or category for the rule. +- Set the computation mode and save + +Usage +===== + +- Go to Sales -> Orders -> Quotations. +- Create a new record and fill the required fields. +- Choose a Pricelist that has a global rule configured (either by + Category or Product). +- Click the **Recompute pricelist global** button to update prices + according to the specified pricelist rules. + +Known issues / Roadmap +====================== + +- Implement automatic application of the pricelist whenever changes are + made to order lines (such as prices, quantities, etc.) or to the + pricelist itself, eliminating the need for manual button clicks. + +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 +------- + +* Tecnativa + +Contributors +------------ + +- `Tecnativa `__ + + - Pedro M. Baeza + - Carlos López + +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-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_pricelist_global_rule/__init__.py b/sale_pricelist_global_rule/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/sale_pricelist_global_rule/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_pricelist_global_rule/__manifest__.py b/sale_pricelist_global_rule/__manifest__.py new file mode 100644 index 00000000000..8dd2d6293f2 --- /dev/null +++ b/sale_pricelist_global_rule/__manifest__.py @@ -0,0 +1,14 @@ +{ + "name": "Sale pricelist global rule", + "version": "17.0.1.0.0", + "summary": "Apply a global rule to all sale order", + "author": "Tecnativa, Odoo Community Association (OCA)", + "category": "Sales Management", + "website": "https://github.com/OCA/sale-workflow", + "depends": [ + "sale", + ], + "data": ["views/product_pricelist_item_views.xml", "views/sale_order_views.xml"], + "installable": True, + "license": "AGPL-3", +} diff --git a/sale_pricelist_global_rule/i18n/sale_pricelist_global_rule.pot b/sale_pricelist_global_rule/i18n/sale_pricelist_global_rule.pot new file mode 100644 index 00000000000..584b814d5f5 --- /dev/null +++ b/sale_pricelist_global_rule/i18n/sale_pricelist_global_rule.pot @@ -0,0 +1,100 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_pricelist_global_rule +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: sale_pricelist_global_rule +#: model:ir.model.fields,field_description:sale_pricelist_global_rule.field_product_pricelist_item__applied_on +msgid "Apply On" +msgstr "" + +#. module: sale_pricelist_global_rule +#: model:ir.model.fields.selection,name:sale_pricelist_global_rule.selection__product_pricelist_item__applied_on__5_global_product_category +msgid "Global - Product category" +msgstr "" + +#. module: sale_pricelist_global_rule +#: model:ir.model.fields.selection,name:sale_pricelist_global_rule.selection__product_pricelist_item__applied_on__4_global_product_template +msgid "Global - Product template" +msgstr "" + +#. module: sale_pricelist_global_rule +#: code:addons/sale_pricelist_global_rule/models/product_pricelist.py:0 +#, python-format +msgid "Global category: %s" +msgstr "" + +#. module: sale_pricelist_global_rule +#: code:addons/sale_pricelist_global_rule/models/product_pricelist.py:0 +#, python-format +msgid "Global product: %s" +msgstr "" + +#. module: sale_pricelist_global_rule +#: model:ir.model.fields,field_description:sale_pricelist_global_rule.field_sale_order__has_pricelist_global +msgid "Has Pricelist Global" +msgstr "" + +#. module: sale_pricelist_global_rule +#: model:ir.model.fields,field_description:sale_pricelist_global_rule.field_sale_order__need_recompute_pricelist_global +msgid "Need Recompute Pricelist Global" +msgstr "" + +#. module: sale_pricelist_global_rule +#: code:addons/sale_pricelist_global_rule/models/product_pricelist.py:0 +#, python-format +msgid "" +"Please specify the category for which this global rule should be applied" +msgstr "" + +#. module: sale_pricelist_global_rule +#: code:addons/sale_pricelist_global_rule/models/product_pricelist.py:0 +#, python-format +msgid "" +"Please specify the product for which this global rule should be applied" +msgstr "" + +#. module: sale_pricelist_global_rule +#: model:ir.model,name:sale_pricelist_global_rule.model_product_pricelist +msgid "Pricelist" +msgstr "" + +#. module: sale_pricelist_global_rule +#: model:ir.model.fields,help:sale_pricelist_global_rule.field_product_pricelist_item__applied_on +msgid "Pricelist Item applicable on selected option" +msgstr "" + +#. module: sale_pricelist_global_rule +#: model:ir.model,name:sale_pricelist_global_rule.model_product_pricelist_item +msgid "Pricelist Rule" +msgstr "" + +#. module: sale_pricelist_global_rule +#: model:ir.model.fields,field_description:sale_pricelist_global_rule.field_product_pricelist_item__global_product_tmpl_id +msgid "Product" +msgstr "" + +#. module: sale_pricelist_global_rule +#: model:ir.model.fields,field_description:sale_pricelist_global_rule.field_product_pricelist_item__global_categ_id +msgid "Product Category" +msgstr "" + +#. module: sale_pricelist_global_rule +#: model_terms:ir.ui.view,arch_db:sale_pricelist_global_rule.view_sale_order_form +msgid "Recompute pricelist global" +msgstr "" + +#. module: sale_pricelist_global_rule +#: model:ir.model,name:sale_pricelist_global_rule.model_sale_order +msgid "Sales Order" +msgstr "" diff --git a/sale_pricelist_global_rule/migrations/17.0.1.0.0/pre-migration.py b/sale_pricelist_global_rule/migrations/17.0.1.0.0/pre-migration.py new file mode 100644 index 00000000000..9a528bfa1bc --- /dev/null +++ b/sale_pricelist_global_rule/migrations/17.0.1.0.0/pre-migration.py @@ -0,0 +1,22 @@ +# Copyright 2025 Tecnativa - Carlos Lopez +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + openupgrade.logged_query( + env.cr, + """UPDATE product_pricelist_item + SET applied_on='3_1_global_product_template' + WHERE applied_on = '4_global_product_template' + """, + ) + openupgrade.logged_query( + env.cr, + """UPDATE product_pricelist_item + SET applied_on='3_2_global_product_category' + WHERE applied_on = '5_global_product_category' + """, + ) diff --git a/sale_pricelist_global_rule/models/__init__.py b/sale_pricelist_global_rule/models/__init__.py new file mode 100644 index 00000000000..afb6ccc5fac --- /dev/null +++ b/sale_pricelist_global_rule/models/__init__.py @@ -0,0 +1,3 @@ +from . import sale_order +from . import product_pricelist +from . import sale_order_line diff --git a/sale_pricelist_global_rule/models/product_pricelist.py b/sale_pricelist_global_rule/models/product_pricelist.py new file mode 100644 index 00000000000..5631023bd6e --- /dev/null +++ b/sale_pricelist_global_rule/models/product_pricelist.py @@ -0,0 +1,179 @@ +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class ProductPricelistItem(models.Model): + _inherit = "product.pricelist.item" + + applied_on = fields.Selection( + selection_add=[ + ("3_1_global_product_template", "Global - Product template"), + ("3_2_global_product_category", "Global - Product category"), + ], + ondelete={ + "3_1_global_product_template": "set default", + "3_2_global_product_category": "set default", + }, + ) + global_product_tmpl_id = fields.Many2one( + "product.template", + "Product", + ondelete="cascade", + check_company=True, + ) + global_categ_id = fields.Many2one( + "product.category", + "Product Category", + ondelete="cascade", + ) + + @api.constrains( + "product_id", + "product_tmpl_id", + "categ_id", + "global_product_tmpl_id", + "global_categ_id", + ) + def _check_product_consistency(self): + res = super()._check_product_consistency() + for item in self: + if ( + item.applied_on == "3_2_global_product_category" + and not item.global_categ_id + ): + raise ValidationError( + _( + "Please specify the category " + "for which this global rule should be applied" + ) + ) + elif ( + item.applied_on == "3_1_global_product_template" + and not item.global_product_tmpl_id + ): + raise ValidationError( + _( + "Please specify the product " + "for which this global rule should be applied" + ) + ) + return res + + @api.depends( + "applied_on", + "categ_id", + "product_tmpl_id", + "product_id", + "global_product_tmpl_id", + "global_categ_id", + "compute_price", + "fixed_price", + "pricelist_id", + "percent_price", + "price_discount", + "price_surcharge", + ) + def _compute_name_and_price(self): + res = super()._compute_name_and_price() + for item in self: + if ( + item.global_categ_id + and item.applied_on == "3_2_global_product_category" + ): + item.name = _("Global category: %s") % ( + item.global_categ_id.display_name + ) + elif ( + item.global_product_tmpl_id + and item.applied_on == "3_1_global_product_template" + ): + item.name = _("Global product: %s") % ( + item.global_product_tmpl_id.display_name + ) + return res + + @api.model_create_multi + def create(self, vals_list): + for values in vals_list: + if values.get("applied_on", False): + # Ensure item consistency for later searches. + applied_on = values["applied_on"] + if applied_on == "3_2_global_product_category": + values.update( + { + "product_id": None, + "product_tmpl_id": None, + "categ_id": None, + "global_product_tmpl_id": None, + } + ) + elif applied_on == "3_1_global_product_template": + values.update( + { + "product_id": None, + "product_tmpl_id": None, + "categ_id": None, + "global_categ_id": None, + } + ) + return super().create(vals_list) + + def write(self, values): + if values.get("applied_on", False): + # Ensure item consistency for later searches. + applied_on = values["applied_on"] + if applied_on == "3_2_global_product_category": + values.update( + { + "product_id": None, + "product_tmpl_id": None, + "categ_id": None, + "global_product_tmpl_id": None, + } + ) + elif applied_on == "3_1_global_product_template": + values.update( + { + "product_id": None, + "product_tmpl_id": None, + "categ_id": None, + "global_categ_id": None, + } + ) + return super().write(values) + + def _is_applicable_for(self, product, qty_in_product_uom): + """Check whether the current rule is valid + for the given sale order and cummulated quantity. + :param product_template: browse_record(product.template) + :param qty_data: + dict{ + by_categ: dict{record: qty, ...}, + by_template: dict{record: qty, ...} + } + :returns: Whether rules is valid or not + :rtype: bool + """ + self.ensure_one() + qty_data = self.env.context.get("pricelist_global_cummulative_quantity", {}) + if not qty_data or self.applied_on not in [ + "3_1_global_product_template", + "3_2_global_product_category", + ]: + return super()._is_applicable_for(product, qty_in_product_uom) + is_applicable = True + if self.applied_on == "3_1_global_product_template": + total_qty = qty_data["by_template"].get(product.product_tmpl_id, 0.0) + if self.min_quantity and total_qty < self.min_quantity: + is_applicable = False + elif self.global_product_tmpl_id != product.product_tmpl_id: + is_applicable = False + elif self.applied_on == "3_2_global_product_category": + total_qty = qty_data["by_categ"].get(product.categ_id, 0.0) + if self.min_quantity and total_qty < self.min_quantity: + is_applicable = False + elif not product.categ_id.parent_path.startswith( + self.global_categ_id.parent_path + ): + is_applicable = False + return is_applicable diff --git a/sale_pricelist_global_rule/models/sale_order.py b/sale_pricelist_global_rule/models/sale_order.py new file mode 100644 index 00000000000..58f68f25ae6 --- /dev/null +++ b/sale_pricelist_global_rule/models/sale_order.py @@ -0,0 +1,73 @@ +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + need_recompute_pricelist_global = fields.Boolean() + has_pricelist_global = fields.Boolean(compute="_compute_has_pricelist_global") + + def _get_cummulative_quantity(self): + """Compute the cummulative quantity of products in the sale order. + :returns: dict{ + by_template: {product.template: qty}, + by_categ: {product.category: qty}} + } + """ + self.ensure_one() + qty_data = { + "by_template": {}, + "by_categ": {}, + } + for line in self.order_line.filtered("product_id"): + qty_in_product_uom = line.product_uom_qty + # Final unit price is computed + # according to `qty` in the default `uom_id`. + if line.product_uom != line.product_id.uom_id: + qty_in_product_uom = line.product_uom._compute_quantity( + qty_in_product_uom, line.product_id.uom_id + ) + key_template = line.product_id.product_tmpl_id + key_categ = line.product_id.categ_id + qty_data["by_template"].setdefault(key_template, 0.0) + qty_data["by_template"][key_template] += qty_in_product_uom + qty_data["by_categ"].setdefault(key_categ, 0.0) + qty_data["by_categ"][key_categ] += qty_in_product_uom + return qty_data + + @api.depends("pricelist_id") + def _compute_has_pricelist_global(self): + for sale in self: + if not sale.pricelist_id: + sale.has_pricelist_global = False + continue + qty_data = self._get_cummulative_quantity() + pricelist = sale.pricelist_id.with_context( + pricelist_global_cummulative_quantity=qty_data + ) + suitable_rule = self.env["product.pricelist.item"] + for line in sale.order_line: + suitable_rule = pricelist._get_product_rule( + line.product_id, + quantity=line.product_uom_qty or 1.0, + uom=line.product_uom, + date=line.order_id.date_order, + ) + if suitable_rule: + break + sale.has_pricelist_global = bool(suitable_rule) + + @api.onchange("order_line") + def _onchange_need_recompute_pricelist_global(self): + self.need_recompute_pricelist_global = True + + def button_compute_pricelist_global_rule(self): + self.ensure_one() + # Clear existing discounts before recomputing. + self.order_line.write({"discount": 0.0}) + qty_data = self._get_cummulative_quantity() + sale_order = self.with_context(pricelist_global_cummulative_quantity=qty_data) + sale_order.order_line._compute_pricelist_item_id() + sale_order.order_line._compute_price_unit() + sale_order.order_line._compute_discount() + self.need_recompute_pricelist_global = False diff --git a/sale_pricelist_global_rule/models/sale_order_line.py b/sale_pricelist_global_rule/models/sale_order_line.py new file mode 100644 index 00000000000..b7a73f17a94 --- /dev/null +++ b/sale_pricelist_global_rule/models/sale_order_line.py @@ -0,0 +1,22 @@ +from odoo import models + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def _compute_pricelist_item_id(self): + # Compute the cumulative quantity of products in the sale order + # for each line to ensure quantities are not mixed between different orders. + # Store the data in a dictionary to avoid redundant computations + # for the same order multiple times. + sale_data = {} + res = None + for line in self: + if line.order_id not in sale_data: + sale_data[line.order_id] = line.order_id._get_cummulative_quantity() + qty_data = sale_data[line.order_id] + res = super( + SaleOrderLine, + line.with_context(pricelist_global_cummulative_quantity=qty_data), + )._compute_pricelist_item_id() + return res diff --git a/sale_pricelist_global_rule/pyproject.toml b/sale_pricelist_global_rule/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/sale_pricelist_global_rule/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/sale_pricelist_global_rule/readme/CONFIGURE.md b/sale_pricelist_global_rule/readme/CONFIGURE.md new file mode 100644 index 00000000000..cbf8a511ca4 --- /dev/null +++ b/sale_pricelist_global_rule/readme/CONFIGURE.md @@ -0,0 +1,5 @@ +- Go to Sales -\> Products -\> Pricelist. +- Create a new Pricelist and add at least one line with the Apply On + option set to Global - Product template or Global - Product category +- Choose the specific product template or category for the rule. +- Set the computation mode and save diff --git a/sale_pricelist_global_rule/readme/CONTRIBUTORS.md b/sale_pricelist_global_rule/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..a628ebcf08e --- /dev/null +++ b/sale_pricelist_global_rule/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [Tecnativa](https://www.tecnativa.com) + - Pedro M. Baeza + - Carlos López diff --git a/sale_pricelist_global_rule/readme/DESCRIPTION.md b/sale_pricelist_global_rule/readme/DESCRIPTION.md new file mode 100644 index 00000000000..b0faa2b0916 --- /dev/null +++ b/sale_pricelist_global_rule/readme/DESCRIPTION.md @@ -0,0 +1,23 @@ +This module allows configured pricelists to be applied to a sales order +by considering cumulative quantities across all lines. + +**Global by Product Template** + +If a pricelist rule has a min_quantity = 15, and a sales order contains: + +- Line 1: Variant 1, quantity = 8 +- Line 2: Variant 2, quantity = 8 + +**Global by Product Category** + +Similarly, if a pricelist rule has a min_quantity = 20 for products +within a category, and a sales order includes: + +- Line 1: Product 1, quantity = 10 +- Line 2: Product 2, quantity = 10 + +In standard Odoo, pricelist rules would not apply since no single line +meets the minimum quantity. With this module, however, cumulative +quantities across lines allow the pricelist rule to apply, as they meet +the minimum threshold (16 in the product template example and 20 in the +product category example). diff --git a/sale_pricelist_global_rule/readme/ROADMAP.md b/sale_pricelist_global_rule/readme/ROADMAP.md new file mode 100644 index 00000000000..60a12f4c0ed --- /dev/null +++ b/sale_pricelist_global_rule/readme/ROADMAP.md @@ -0,0 +1,3 @@ +- Implement automatic application of the pricelist whenever changes are + made to order lines (such as prices, quantities, etc.) or to the + pricelist itself, eliminating the need for manual button clicks. diff --git a/sale_pricelist_global_rule/readme/USAGE.md b/sale_pricelist_global_rule/readme/USAGE.md new file mode 100644 index 00000000000..76e2a3f94db --- /dev/null +++ b/sale_pricelist_global_rule/readme/USAGE.md @@ -0,0 +1,6 @@ +- Go to Sales -\> Orders -\> Quotations. +- Create a new record and fill the required fields. +- Choose a Pricelist that has a global rule configured (either by + Category or Product). +- Click the **Recompute pricelist global** button to update prices + according to the specified pricelist rules. diff --git a/sale_pricelist_global_rule/static/description/icon.png b/sale_pricelist_global_rule/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/sale_pricelist_global_rule/static/description/icon.png differ diff --git a/sale_pricelist_global_rule/static/description/index.html b/sale_pricelist_global_rule/static/description/index.html new file mode 100644 index 00000000000..7c98df2bb50 --- /dev/null +++ b/sale_pricelist_global_rule/static/description/index.html @@ -0,0 +1,478 @@ + + + + + +Sale pricelist global rule + + + +
+

Sale pricelist global rule

+ + +

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

+

This module allows configured pricelists to be applied to a sales order +by considering cumulative quantities across all lines.

+

Global by Product Template

+

If a pricelist rule has a min_quantity = 15, and a sales order contains:

+
    +
  • Line 1: Variant 1, quantity = 8
  • +
  • Line 2: Variant 2, quantity = 8
  • +
+

Global by Product Category

+

Similarly, if a pricelist rule has a min_quantity = 20 for products +within a category, and a sales order includes:

+
    +
  • Line 1: Product 1, quantity = 10
  • +
  • Line 2: Product 2, quantity = 10
  • +
+

In standard Odoo, pricelist rules would not apply since no single line +meets the minimum quantity. With this module, however, cumulative +quantities across lines allow the pricelist rule to apply, as they meet +the minimum threshold (16 in the product template example and 20 in the +product category example).

+

Table of contents

+ +
+

Configuration

+
    +
  • Go to Sales -> Products -> Pricelist.
  • +
  • Create a new Pricelist and add at least one line with the Apply On +option set to Global - Product template or Global - Product category
  • +
  • Choose the specific product template or category for the rule.
  • +
  • Set the computation mode and save
  • +
+
+
+

Usage

+
    +
  • Go to Sales -> Orders -> Quotations.
  • +
  • Create a new record and fill the required fields.
  • +
  • Choose a Pricelist that has a global rule configured (either by +Category or Product).
  • +
  • Click the Recompute pricelist global button to update prices +according to the specified pricelist rules.
  • +
+
+
+

Known issues / Roadmap

+
    +
  • Implement automatic application of the pricelist whenever changes are +made to order lines (such as prices, quantities, etc.) or to the +pricelist itself, eliminating the need for manual button clicks.
  • +
+
+
+

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

+
    +
  • Tecnativa
  • +
+
+
+

Contributors

+
    +
  • Tecnativa
      +
    • Pedro M. Baeza
    • +
    • Carlos López
    • +
    +
  • +
+
+
+

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-workflow project on GitHub.

+

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

+
+
+
+ + diff --git a/sale_pricelist_global_rule/tests/__init__.py b/sale_pricelist_global_rule/tests/__init__.py new file mode 100644 index 00000000000..0cf22ea016b --- /dev/null +++ b/sale_pricelist_global_rule/tests/__init__.py @@ -0,0 +1 @@ +from . import test_pricelist_global diff --git a/sale_pricelist_global_rule/tests/test_pricelist_global.py b/sale_pricelist_global_rule/tests/test_pricelist_global.py new file mode 100644 index 00000000000..c3ab7b82d92 --- /dev/null +++ b/sale_pricelist_global_rule/tests/test_pricelist_global.py @@ -0,0 +1,968 @@ +from odoo.addons.base.tests.common import TransactionCase + + +class TestPricelistGlobal(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ProductAttribute = cls.env["product.attribute"] + cls.ProductAttributeValue = cls.env["product.attribute.value"] + cls.Product = cls.env["product.product"] + cls.ProductTemplate = cls.env["product.template"] + cls.ProductTemplateAttribute = cls.env["product.template.attribute.line"] + cls.ProductCateg = cls.env["product.category"] + cls.Pricelist = cls.env["product.pricelist"] + cls.PricelistItem = cls.env["product.pricelist.item"] + cls.Partner = cls.env["res.partner"] + cls.SaleOrder = cls.env["sale.order"] + cls.SaleOrderLine = cls.env["sale.order.line"] + cls.attr_size = cls.ProductAttribute.create( + {"name": "sale_pricelist_global_rule Size", "sequence": 1} + ) + cls.attr_color = cls.ProductAttribute.create( + {"name": "sale_pricelist_global_rule Color", "sequence": 2} + ) + cls.size_m = cls.ProductAttributeValue.create( + { + "name": "M", + "attribute_id": cls.attr_size.id, + "sequence": 1, + } + ) + cls.size_l = cls.ProductAttributeValue.create( + { + "name": "L", + "attribute_id": cls.attr_size.id, + "sequence": 2, + } + ) + cls.color_red = cls.ProductAttributeValue.create( + { + "name": "Red", + "attribute_id": cls.attr_color.id, + "sequence": 1, + } + ) + cls.color_black = cls.ProductAttributeValue.create( + { + "name": "Black", + "attribute_id": cls.attr_color.id, + "sequence": 2, + } + ) + cls.categ_1 = cls.ProductCateg.create({"name": "Categ 1"}) + cls.categ_2 = cls.ProductCateg.create({"name": "Categ 2"}) + cls.t_shirt = cls.ProductTemplate.create( + {"name": "T-Shirt", "list_price": 100, "categ_id": cls.categ_1.id} + ) + cls.template_attr_sizes = cls.ProductTemplateAttribute.create( + { + "product_tmpl_id": cls.t_shirt.id, + "attribute_id": cls.attr_size.id, + "value_ids": [(6, 0, [cls.size_m.id, cls.size_l.id])], + } + ) + cls.template_attr_colors = cls.ProductTemplateAttribute.create( + { + "product_tmpl_id": cls.t_shirt.id, + "attribute_id": cls.attr_color.id, + "value_ids": [(6, 0, [cls.color_red.id, cls.color_black.id])], + } + ) + cls.template_attr_size_m = cls.template_attr_sizes.product_template_value_ids[0] + cls.template_attr_size_l = cls.template_attr_sizes.product_template_value_ids[1] + cls.template_attr_color_red = ( + cls.template_attr_colors.product_template_value_ids[0] + ) + cls.template_attr_color_black = ( + cls.template_attr_colors.product_template_value_ids[1] + ) + cls.t_shirt_m_red = cls.t_shirt._get_variant_for_combination( + cls.template_attr_size_m + cls.template_attr_color_red + ) + cls.t_shirt_m_black = cls.t_shirt._get_variant_for_combination( + cls.template_attr_size_m + cls.template_attr_color_black + ) + cls.product_2 = cls.Product.create( + {"name": "Product 2", "list_price": 200, "categ_id": cls.categ_1.id} + ) + cls.product_3 = cls.Product.create( + {"name": "Product 3", "list_price": 300, "categ_id": cls.categ_2.id} + ) + cls.pricelist_base = cls.Pricelist.create({"name": "Base Pricelist"}) + cls.pricelist_global = cls.Pricelist.create({"name": "Global Pricelist"}) + cls.pricelist_item_by_product = cls.PricelistItem.create( + { + "pricelist_id": cls.pricelist_global.id, + "applied_on": "3_1_global_product_template", + "global_product_tmpl_id": cls.t_shirt.id, + "compute_price": "percentage", + "percent_price": 10, + "min_quantity": 15, + } + ) + cls.pricelist_item_by_categ = cls.PricelistItem.create( + { + "pricelist_id": cls.pricelist_global.id, + "applied_on": "3_2_global_product_category", + "global_categ_id": cls.categ_1.id, + "compute_price": "percentage", + "percent_price": 10, + "min_quantity": 20, + } + ) + cls.pricelist_item_base = cls.PricelistItem.create( + { + "pricelist_id": cls.pricelist_base.id, + "applied_on": "1_product", + "product_tmpl_id": cls.t_shirt.id, + "compute_price": "percentage", + "percent_price": 20, + "min_quantity": 5, + } + ) + cls.partner_1 = cls.Partner.create({"name": "Partner 1"}) + cls.partner_2 = cls.Partner.create({"name": "Partner 2"}) + cls.sale_order1 = cls.SaleOrder.create( + { + "partner_id": cls.partner_1.id, + "partner_invoice_id": cls.partner_1.id, + "partner_shipping_id": cls.partner_1.id, + "pricelist_id": cls.pricelist_global.id, + } + ) + cls.sale_line_m_red = cls.SaleOrderLine.create( + { + "order_id": cls.sale_order1.id, + "product_id": cls.t_shirt_m_red.id, + "product_uom_qty": 1, + "price_unit": 100, + } + ) + cls.sale_line_m_black = cls.SaleOrderLine.create( + { + "order_id": cls.sale_order1.id, + "product_id": cls.t_shirt_m_black.id, + "product_uom_qty": 1, + "price_unit": 100, + } + ) + cls.sale_line_2 = cls.SaleOrderLine.create( + { + "order_id": cls.sale_order1.id, + "product_id": cls.product_2.id, + "product_uom_qty": 1, + "price_unit": 200, + } + ) + cls.sale_line_3 = cls.SaleOrderLine.create( + { + "order_id": cls.sale_order1.id, + "product_id": cls.product_3.id, + "product_uom_qty": 1, + "price_unit": 300, + } + ) + cls.env.user.groups_id += cls.env.ref("product.group_discount_per_so_line") + + def test_01_by_product_less_min_quantity(self): + """ + Verify that the total quantity (9) is less than the minimum quantity (10). + product_m_red: qty=4, price=100 + product_m_black: qty=5, price=100 + product_2: qty=1, price=200 + product_3: qty=1, price=300 + """ + self.sale_line_m_red.product_uom_qty = 4 + self.sale_line_m_black.product_uom_qty = 5 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 100) + self.assertEqual(self.sale_line_m_black.price_unit, 100) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertFalse(self.sale_line_m_red.pricelist_item_id) + self.assertFalse(self.sale_line_m_black.pricelist_item_id) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + + def test_02_by_product_fixed_price(self): + """ + Only product_m_red and product_m_black have fixed prices + After applying the global pricelist: min_quantity=15, Total qty=15 + product_m_red: qty=7, price=50(fixed) + product_m_black: qty=8, price=50(fixed) + product_2: qty=1, price=200(unchanged) + product_3: qty=1, price=300(unchanged) + """ + self.pricelist_item_by_product.write( + { + "compute_price": "fixed", + "fixed_price": 50, + } + ) + self.sale_line_m_red.product_uom_qty = 7 + self.sale_line_m_black.product_uom_qty = 8 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 50) + self.assertEqual(self.sale_line_m_black.price_unit, 50) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + + def test_03_by_product_discount(self): + """ + Only product_m_red and product_m_black have 10% discount + After apply the global pricelist: min_quantity=15, Total qty=15 + product_m_red: qty=7, price=90 + product_m_black: qty=8, price=90 + product_2: qty=1, price=200(unchanged) + product_3: qty=1, price=300(unchanged) + """ + self.sale_line_m_red.product_uom_qty = 7 + self.sale_line_m_black.product_uom_qty = 8 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 90) + self.assertEqual(self.sale_line_m_black.price_unit, 90) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + + def test_04_by_product_formula(self): + """ + Only product_m_red and product_m_black have 20% discount + After applying the global pricelist: min_quantity=15, Total qty=15 + product_m_red: qty=7, price=80 + product_m_black: qty=8, price=80 + product_2: qty=1, price=200(unchanged) + product_3: qty=1, price=300(unchanged) + """ + self.pricelist_item_by_product.write( + { + "compute_price": "formula", + "price_discount": 20, + } + ) + self.sale_line_m_red.product_uom_qty = 7 + self.sale_line_m_black.product_uom_qty = 8 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 80) + self.assertEqual(self.sale_line_m_black.price_unit, 80) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + # with surcharge(+) + self.pricelist_item_by_product.write({"price_surcharge": 5}) + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 85) + self.assertEqual(self.sale_line_m_black.price_unit, 85) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + # with surcharge(-) + self.pricelist_item_by_product.write({"price_surcharge": -5}) + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 75) + self.assertEqual(self.sale_line_m_black.price_unit, 75) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + + def test_05_by_product_base_other_pricelist_normal(self): + """ + Base pricelist offers a 20% discount on t_shirt with min_quantity=5 + Global pricelist offers a 10% discount on product_m_red with min_quantity=15 + Case 1: + - Total quantity=8. + - Global pricelist does not apply. + - Base pricelist is not evaluated. + Case 2: + - Total qty=15 + - Base pricelist: + - Applies only to sale_line_m_black(quantity=11) + - Global pricelist: + - sale_line_m_red = 100 * 10% discount = 90 + - sale_line_m_black: + - Base price = 100 * 20% discount (from base pricelist=80) + - Final price = 80 * 10% discount = 72 + Case 3: + - Total qty=16 + - Base pricelist: + - Applies to both sale_line_m_red and + sale_line_m_black (both with quantity=8) + - Global pricelist: + - Base price = 100 * 20% discount (from base pricelist=80) + - Final price = 80 * 10% discount = 72 + """ + self.pricelist_item_by_product.write( + { + "base": "pricelist", + "base_pricelist_id": self.pricelist_base.id, + } + ) + # case 1 + self.sale_line_m_red.product_uom_qty = 4 + self.sale_line_m_black.product_uom_qty = 4 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 100) + self.assertEqual(self.sale_line_m_black.price_unit, 100) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertFalse(self.sale_line_m_red.pricelist_item_id) + self.assertFalse(self.sale_line_m_black.pricelist_item_id) + # case 2 + self.sale_line_m_red.product_uom_qty = 4 + self.sale_line_m_black.product_uom_qty = 11 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 90) + self.assertEqual(self.sale_line_m_black.price_unit, 72) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + # case 3 + self.sale_line_m_red.product_uom_qty = 8 + self.sale_line_m_black.product_uom_qty = 8 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 72) + self.assertEqual(self.sale_line_m_black.price_unit, 72) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + + def test_06_by_product_base_other_pricelist_global(self): + """ + base pricelist have 20% discount for t_shirt with min_quantity=5 + global pricelist have 10% discount for product_m_red with min_quantity=15 + Case 1: Total qty=8, not apply global pricelist, no eval base pricelist + Case 2: Total qty=16, apply global pricelist + - base pricelist: + applicable for sale_line_m_red and sale_line_m_black (total quantity=16) + - global pricelist: + - base_price = 100 * 20% discount (from base pricelist) + - final_price = 80 * 10% discount = 72 + + """ + self.pricelist_item_base.write( + { + "applied_on": "3_1_global_product_template", + "global_product_tmpl_id": self.t_shirt.id, + } + ) + self.pricelist_item_by_product.write( + { + "base": "pricelist", + "base_pricelist_id": self.pricelist_base.id, + } + ) + # case 1 + self.sale_line_m_red.product_uom_qty = 4 + self.sale_line_m_black.product_uom_qty = 4 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 100) + self.assertEqual(self.sale_line_m_black.price_unit, 100) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertFalse(self.sale_line_m_red.pricelist_item_id) + self.assertFalse(self.sale_line_m_black.pricelist_item_id) + # case 2 + self.sale_line_m_red.product_uom_qty = 8 + self.sale_line_m_black.product_uom_qty = 8 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 72) + self.assertEqual(self.sale_line_m_black.price_unit, 72) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + + def test_11_by_categ_less_min_quantity(self): + """ + Verify that the total quantity (19) is less than the minimum quantity (20). + product_m_red: qty=4, price=100 + product_m_black: qty=5, price=100 + product_2: qty=10, price=200 + product_3: qty=10, price=300 + """ + self.sale_line_m_red.product_uom_qty = 4 + self.sale_line_m_black.product_uom_qty = 5 + self.sale_line_2.product_uom_qty = 10 + self.sale_line_3.product_uom_qty = 10 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 100) + self.assertEqual(self.sale_line_m_black.price_unit, 100) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertFalse(self.sale_line_m_red.pricelist_item_id) + self.assertFalse(self.sale_line_m_black.pricelist_item_id) + + def test_12_by_categ_fixed_price(self): + """ + Only product_m_red and product_m_black, product_2 have fixed prices. + After applying the global pricelist: min_quantity=20, Total qty=24 + product_m_red: qty=4, price=50(fixed) + product_m_black: qty=5, price=50(fixed) + product_2: qty=15, price=50(fixed) + product_3: qty=10, price=300(unchanged) + """ + self.pricelist_item_by_categ.write( + { + "compute_price": "fixed", + "fixed_price": 50, + } + ) + self.sale_line_m_red.product_uom_qty = 4 + self.sale_line_m_black.product_uom_qty = 5 + self.sale_line_2.product_uom_qty = 15 + self.sale_line_3.product_uom_qty = 10 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 50) + self.assertEqual(self.sale_line_m_black.price_unit, 50) + self.assertEqual(self.sale_line_2.price_unit, 50) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_categ + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_categ + ) + + def test_13_by_categ_discount(self): + """ + Only product_m_red and product_m_black, product_2 have 10% discount + After apply global pricelist: min_quantity=20, Total qty=24 + product_m_red: qty=4, price=90 + product_m_black: qty=5, price=90 + product_2: qty=15, price=180 + product_3: qty=10, price=300(unchanged) + """ + self.sale_line_m_red.product_uom_qty = 4 + self.sale_line_m_black.product_uom_qty = 5 + self.sale_line_2.product_uom_qty = 15 + self.sale_line_3.product_uom_qty = 10 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 90) + self.assertEqual(self.sale_line_m_black.price_unit, 90) + self.assertEqual(self.sale_line_2.price_unit, 180) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_categ + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_categ + ) + + def test_14_by_categ_formula(self): + """ + Only product_m_red and product_m_black, product_2 have 20% discount + After apply global pricelist: min_quantity=20, Total qty=24 + product_m_red: qty=4, price=80 + product_m_black: qty=5, price=80 + product_2: qty=15, price=160 + product_3: qty=10, price=300(unchanged) + """ + self.pricelist_item_by_categ.write( + { + "compute_price": "formula", + "price_discount": 20, + } + ) + self.sale_line_m_red.product_uom_qty = 4 + self.sale_line_m_black.product_uom_qty = 5 + self.sale_line_2.product_uom_qty = 15 + self.sale_line_3.product_uom_qty = 10 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 80) + self.assertEqual(self.sale_line_m_black.price_unit, 80) + self.assertEqual(self.sale_line_2.price_unit, 160) + self.assertEqual(self.sale_line_3.price_unit, 300) + # with surcharge(+) + self.pricelist_item_by_categ.write({"price_surcharge": 5}) + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 85) + self.assertEqual(self.sale_line_m_black.price_unit, 85) + self.assertEqual(self.sale_line_2.price_unit, 165) + self.assertEqual(self.sale_line_3.price_unit, 300) + # with surcharge(-) + self.pricelist_item_by_categ.write({"price_surcharge": -5}) + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 75) + self.assertEqual(self.sale_line_m_black.price_unit, 75) + self.assertEqual(self.sale_line_2.price_unit, 155) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_categ + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_categ + ) + + def test_15_by_categ_base_other_pricelist_normal(self): + """ + base pricelist offers a 20% discount on t_shirt with min_quantity=5 + global pricelist offers a 10% discount on categ1 with min_quantity=20 + Case 1: + - Total qty=9 + - Global pricelist does not apply. + - Base pricelist is not evaluated. + Case 2: + - Total qty=20 + - Base pricelist: + - applicable only to sale_line_m_black(quantity=8) + - Global pricelist: + - sale_line_m_red = 100 * 10% discount = 90 + - sale_line_2 = 200 * 10% discount = 180 + - sale_line_m_black: + - base_price = 100 * 20% discount (from base pricelist) + - final_price = 80 * 10% discount = 72 + Case 3: + - Total qty=22 + - Base pricelist: + applicable on sale_line_m_red (with quantity=6) + applicable on sale_line_m_black (with quantity=8) + applicable on sale_line_2 (quantity=8) + - Global pricelist: + - base_price = 100 * 20% discount (from base pricelist) + - final_price = 80 * 10% discount = 72 + """ + self.pricelist_item_by_categ.write( + { + "base": "pricelist", + "base_pricelist_id": self.pricelist_base.id, + } + ) + # case 1 + self.sale_line_m_red.product_uom_qty = 4 + self.sale_line_m_black.product_uom_qty = 4 + self.sale_line_2.product_uom_qty = 1 + self.sale_line_3.product_uom_qty = 10 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 100) + self.assertEqual(self.sale_line_m_black.price_unit, 100) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertFalse(self.sale_line_m_red.pricelist_item_id) + self.assertFalse(self.sale_line_m_black.pricelist_item_id) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + # case 2 + self.sale_line_m_red.product_uom_qty = 4 + self.sale_line_m_black.product_uom_qty = 8 + self.sale_line_2.product_uom_qty = 8 + self.sale_line_3.product_uom_qty = 10 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 90) + self.assertEqual(self.sale_line_m_black.price_unit, 72) + self.assertEqual(self.sale_line_2.price_unit, 180) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_categ + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_categ + ) + self.assertEqual( + self.sale_line_2.pricelist_item_id, self.pricelist_item_by_categ + ) + self.assertFalse(self.sale_line_3.pricelist_item_id) + # case 3 + self.sale_line_m_red.product_uom_qty = 6 + self.sale_line_m_black.product_uom_qty = 8 + self.sale_line_2.product_uom_qty = 8 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 72) + self.assertEqual(self.sale_line_m_black.price_unit, 72) + self.assertEqual(self.sale_line_2.price_unit, 180) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_categ + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_categ + ) + self.assertEqual( + self.sale_line_2.pricelist_item_id, self.pricelist_item_by_categ + ) + self.assertFalse(self.sale_line_3.pricelist_item_id) + + def test_16_by_categ_base_other_pricelist_global(self): + """ + Base pricelist offers a 20% discount for t_shirt with min_quantity=5 + global pricelist offers a 10% discount for categ1 with min_quantity=20 + Case 1: + - Total quantity=9. + - Global pricelist does not apply. + - Base pricelist is not evaluated. + Case 2: + - Total qty=21 + - Base pricelist: + - Applicable on sale_line_m_red + and sale_line_m_black (both with quantity=7) + - Applicable on sale_line_2 (quantity=7) + - Global pricelist: + - Applicable on sale_line_m_red and sale_line_m_black + - Base price = 100 * 20% discount (from Base pricelist) + - Final price = 80 * 10% discount = 72 + - Applicable on sale_line_2: + - Base price = 200 * 20% discount (from Base pricelist) + - Final price = 160 * 10% discount = 144 + """ + self.pricelist_item_base.write( + { + "applied_on": "3_2_global_product_category", + "global_categ_id": self.categ_1.id, + } + ) + self.pricelist_item_by_categ.write( + { + "base": "pricelist", + "base_pricelist_id": self.pricelist_base.id, + } + ) + # case 1 + self.sale_line_m_red.product_uom_qty = 4 + self.sale_line_m_black.product_uom_qty = 4 + self.sale_line_2.product_uom_qty = 1 + self.sale_line_3.product_uom_qty = 10 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 100) + self.assertEqual(self.sale_line_m_black.price_unit, 100) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertFalse(self.sale_line_m_red.pricelist_item_id) + self.assertFalse(self.sale_line_m_black.pricelist_item_id) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + # case 2 + self.sale_line_m_red.product_uom_qty = 7 + self.sale_line_m_black.product_uom_qty = 7 + self.sale_line_2.product_uom_qty = 7 + self.sale_line_3.product_uom_qty = 10 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 72) + self.assertEqual(self.sale_line_m_black.price_unit, 72) + self.assertEqual(self.sale_line_2.price_unit, 144) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_categ + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_categ + ) + self.assertEqual( + self.sale_line_2.pricelist_item_id, self.pricelist_item_by_categ + ) + self.assertFalse(self.sale_line_3.pricelist_item_id) + + def test_pricelist_by_dates(self): + """ + Case 1: Not available due to date_start + Case 2: Not available due to date_end + Case 3: 10% discount applied to product_m_red and product_m_black + """ + self.pricelist_item_by_product.write( + { + "date_start": "2024-12-31 00:00:00", + "date_end": "2024-12-31 23:59:59", + } + ) + # case 1 + self.sale_order1.date_order = "2024-12-30 23:59:59" + self.sale_line_m_red.product_uom_qty = 8 + self.sale_line_m_black.product_uom_qty = 8 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 100) + self.assertEqual(self.sale_line_m_black.price_unit, 100) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertFalse(self.sale_line_m_red.pricelist_item_id) + self.assertFalse(self.sale_line_m_black.pricelist_item_id) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + # case 2 + self.sale_order1.date_order = "2025-01-01 00:00:00" + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 100) + self.assertEqual(self.sale_line_m_black.price_unit, 100) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertFalse(self.sale_line_m_red.pricelist_item_id) + self.assertFalse(self.sale_line_m_black.pricelist_item_id) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + # case 3 + self.sale_order1.date_order = "2024-12-31 00:00:00" + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 90) + self.assertEqual(self.sale_line_m_black.price_unit, 90) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + + def test_pricelist_by_uom(self): + """ + Global pricelist 10% discount for t_shirt with min_quantity=15 + Case 1: + - Total qty=2 Units(global pricelist not applied) + - product_m_red: uom=Units, qty=1, price=100 + - product_m_black: uom=Units, qty=1, price=100 + Case 2: + - Total qty=13 Units(global pricelist not applied) + - product_m_red: uom=Dozen, qty=1, price=100 + - product_m_black: uom=Units, qty=1, price=100 + Case 3: + - Total qty=18 Units(global pricelist applied) + - product_m_red: uom=Dozen, qty=1, price=90 + - product_m_black: uom=Units, qty=6, price=90 + """ + # case 1 + self.sale_line_m_red.product_uom_qty = 1 + self.sale_line_m_black.product_uom_qty = 1 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 100) + self.assertEqual(self.sale_line_m_black.price_unit, 100) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertFalse(self.sale_line_m_red.pricelist_item_id) + self.assertFalse(self.sale_line_m_black.pricelist_item_id) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + # case 2 + self.sale_line_m_red.product_uom_qty = 1 + self.sale_line_m_red.product_uom = self.env.ref("uom.product_uom_dozen") + self.sale_line_m_black.product_uom_qty = 1 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 1200) + self.assertEqual(self.sale_line_m_black.price_unit, 100) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertFalse(self.sale_line_m_red.pricelist_item_id) + self.assertFalse(self.sale_line_m_black.pricelist_item_id) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + # case 3 + self.sale_line_m_red.product_uom_qty = 1 + self.sale_line_m_red.product_uom = self.env.ref("uom.product_uom_dozen") + self.sale_line_m_black.product_uom_qty = 6 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 1080) + self.assertEqual(self.sale_line_m_black.price_unit, 90) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + + def test_pricelist_visible_discount(self): + """ + Base pricelist: 20% discount for t_shirt with min_quantity=5 + Global pricelist: 10% discount for t_shirt with min_quantity=15 + All cases: + - product_m_red: qty=8, applies global pricelist(total 16) + - product_m_black: qty=8, applies global pricelist(total 16) + - product_2: qty=1, price=200, discount=0. No pricelist applied + - product_3: qty=1, price=300, discount=0. No pricelist applied + Case 1: + - Based on list price + - Global pricelist discount policy: with_discount + - product_m_red: price=90, discount=0 + - product_m_black: price=90, discount=0 + Case 2: + - Based on list price + - Global pricelist discount policy: without_discount + - product_m_red: price=100, discount=10 + - product_m_black: price=100, discount=10 + Case 3: + - Based on other pricelist + - Global pricelist discount policy: with_discount + - base pricelist discount_policywith_discount + - product_m_red: price=72, discount=0 + - product_m_black: price=72, discount=0 + Case 4: + - Based on other pricelist + - Global pricelist discount policy: without_discount + - base pricelist discount_policy: with_discount + - product_m_red: price=80, discount=10 + - product_m_black: price=80, discount=10 + Case 5: + - Based on other pricelist + - Global pricelist discount policy: with_discount + - base pricelist discount_policy: without_discount + - product_m_red: price=80, discount=10 + - product_m_black: price=80, discount=10 + Case 6: + - Based on other pricelist + - Global pricelist discount policy: without_discount + - base pricelist discount_policy: without_discount + - product_m_red: price=100, discount=28 + - product_m_black: price=100, discount=28 + """ + # case 1 + self.pricelist_global.write({"discount_policy": "with_discount"}) + self.sale_line_m_red.product_uom_qty = 8 + self.sale_line_m_black.product_uom_qty = 8 + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 90) + self.assertEqual(self.sale_line_m_red.discount, 0) + self.assertEqual(self.sale_line_m_black.price_unit, 90) + self.assertEqual(self.sale_line_m_black.discount, 0) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_2.discount, 0) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual(self.sale_line_3.discount, 0) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + # case 2 + self.pricelist_global.write({"discount_policy": "without_discount"}) + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 100) + self.assertEqual(self.sale_line_m_red.discount, 10) + self.assertEqual(self.sale_line_m_black.price_unit, 100) + self.assertEqual(self.sale_line_m_black.discount, 10) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_2.discount, 0) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual(self.sale_line_3.discount, 0) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + # case 3 + self.pricelist_item_by_product.write( + { + "base": "pricelist", + "base_pricelist_id": self.pricelist_base.id, + } + ) + self.pricelist_global.write({"discount_policy": "with_discount"}) + self.pricelist_base.write({"discount_policy": "with_discount"}) + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 72) + self.assertEqual(self.sale_line_m_red.discount, 0) + self.assertEqual(self.sale_line_m_black.price_unit, 72) + self.assertEqual(self.sale_line_m_black.discount, 0) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_2.discount, 0) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual(self.sale_line_3.discount, 0) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + # case 4 + self.pricelist_global.write({"discount_policy": "without_discount"}) + self.pricelist_base.write({"discount_policy": "with_discount"}) + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 80) + self.assertEqual(self.sale_line_m_red.discount, 10) + self.assertEqual(self.sale_line_m_black.price_unit, 80) + self.assertEqual(self.sale_line_m_black.discount, 10) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_2.discount, 0) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual(self.sale_line_3.discount, 0) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + # case 5 + self.pricelist_global.write({"discount_policy": "with_discount"}) + self.pricelist_base.write({"discount_policy": "without_discount"}) + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 72) + self.assertEqual(self.sale_line_m_red.discount, 0) + self.assertEqual(self.sale_line_m_black.price_unit, 72) + self.assertEqual(self.sale_line_m_black.discount, 0) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_2.discount, 0) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual(self.sale_line_3.discount, 0) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) + # case 6 + self.pricelist_global.write({"discount_policy": "without_discount"}) + self.pricelist_base.write({"discount_policy": "without_discount"}) + self.sale_order1.button_compute_pricelist_global_rule() + self.assertEqual(self.sale_line_m_red.price_unit, 100) + self.assertEqual(self.sale_line_m_red.discount, 28) + self.assertEqual(self.sale_line_m_black.price_unit, 100) + self.assertEqual(self.sale_line_m_black.discount, 28) + self.assertEqual(self.sale_line_2.price_unit, 200) + self.assertEqual(self.sale_line_2.discount, 0) + self.assertEqual(self.sale_line_3.price_unit, 300) + self.assertEqual(self.sale_line_3.discount, 0) + self.assertEqual( + self.sale_line_m_red.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertEqual( + self.sale_line_m_black.pricelist_item_id, self.pricelist_item_by_product + ) + self.assertFalse(self.sale_line_2.pricelist_item_id) + self.assertFalse(self.sale_line_3.pricelist_item_id) diff --git a/sale_pricelist_global_rule/views/product_pricelist_item_views.xml b/sale_pricelist_global_rule/views/product_pricelist_item_views.xml new file mode 100644 index 00000000000..dbe41013987 --- /dev/null +++ b/sale_pricelist_global_rule/views/product_pricelist_item_views.xml @@ -0,0 +1,26 @@ + + + + + view.product.pricelist.item.form + product.pricelist.item + + + + + + + + + + diff --git a/sale_pricelist_global_rule/views/sale_order_views.xml b/sale_pricelist_global_rule/views/sale_order_views.xml new file mode 100644 index 00000000000..8687d32a430 --- /dev/null +++ b/sale_pricelist_global_rule/views/sale_order_views.xml @@ -0,0 +1,29 @@ + + + + + view.sale.order.form + sale.order + + + +