diff --git a/sale_pricelist_global_rule/README.rst b/sale_pricelist_global_rule/README.rst new file mode 100644 index 00000000000..d2db8f5fae0 --- /dev/null +++ b/sale_pricelist_global_rule/README.rst @@ -0,0 +1,118 @@ +========================== +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/16.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-16-0/sale-workflow-16-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=16.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..956abf01f54 --- /dev/null +++ b/sale_pricelist_global_rule/__manifest__.py @@ -0,0 +1,14 @@ +{ + "name": "Sale pricelist global rule", + "version": "16.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/models/__init__.py b/sale_pricelist_global_rule/models/__init__.py new file mode 100644 index 00000000000..d9cbc1df214 --- /dev/null +++ b/sale_pricelist_global_rule/models/__init__.py @@ -0,0 +1,2 @@ +from . import sale_order +from . import product_pricelist 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..733cbd29d47 --- /dev/null +++ b/sale_pricelist_global_rule/models/product_pricelist.py @@ -0,0 +1,374 @@ +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +def get_family_category(category): + subcategories = category.child_id + all_subcategories = subcategories + for subcategory in subcategories: + all_subcategories |= get_family_category(subcategory) + return all_subcategories + + +class ProductPricelist(models.Model): + _inherit = "product.pricelist" + + def _compute_price_rule_get_items( + self, products_qty_partner, date, uom_id, prod_tmpl_ids, prod_ids, categ_ids + ): + self.ensure_one() + # Load all rules + self.env["product.pricelist.item"].flush( + ["price", "currency_id", "company_id", "active"] + ) + self.env.cr.execute( + """ + SELECT + item.id + FROM + product_pricelist_item AS item + LEFT JOIN product_category AS categ ON item.categ_id = categ.id + WHERE + (item.product_tmpl_id IS NULL OR item.product_tmpl_id = any(%s)) + AND (item.product_id IS NULL OR item.product_id = any(%s)) + AND (item.categ_id IS NULL OR item.categ_id = any(%s)) + AND (item.pricelist_id = %s) + AND (item.date_start IS NULL OR item.date_start<=%s) + AND (item.date_end IS NULL OR item.date_end>=%s) + AND (item.active = TRUE) + ORDER BY + item.applied_on, item.min_quantity desc, categ.complete_name desc, item.id desc + """, + (prod_tmpl_ids, prod_ids, categ_ids, self.id, date, date), + ) + # NOTE: if you change `order by` on that query, make sure it matches + # _order from model to avoid inconstencies and undeterministic issues. + + item_ids = [x[0] for x in self.env.cr.fetchall()] + items = self.env["product.pricelist.item"].browse(item_ids) + return items.filtered( + lambda item: item.applied_on + not in ["4_global_product_template", "5_global_product_category"] + ) + + def _compute_price_rule_get_items_globals(self, date, prod_tmpl_ids, categ_ids): + self.ensure_one() + # Load all global rules + # inspired by _compute_price_rule_get_items + # but only for global rules + self.env["product.pricelist.item"].flush( + ["price", "currency_id", "company_id", "active", "date_start", "date_end"] + ) + self.env.cr.execute( + """ + SELECT + item.id + FROM + product_pricelist_item AS item + LEFT JOIN product_category AS categ + ON item.global_categ_id = categ.id + WHERE + (item.global_product_tmpl_id IS NULL OR item.global_product_tmpl_id = any(%s)) + AND (item.global_categ_id IS NULL OR item.global_categ_id = any(%s)) + AND (item.pricelist_id = %s) + AND (item.date_start IS NULL OR item.date_start<=%s) + AND (item.date_end IS NULL OR item.date_end>=%s) + AND (item.active = TRUE) + AND item.applied_on IN ( + '4_global_product_template', + '5_global_product_category' + ) + ORDER BY + item.applied_on, item.min_quantity desc, categ.complete_name desc, item.id desc + """, + (prod_tmpl_ids, categ_ids, self.id, date, date), + ) + # NOTE: if you change `order by` on that query, make sure it matches + # _order from model to avoid inconstencies and undeterministic issues. + + item_ids = [x[0] for x in self.env.cr.fetchall()] + return self.env["product.pricelist.item"].browse(item_ids) + + def _extract_products_and_categs_from_sale(self, sale): + """ + Extract unique product templates and categories (including their parents) + :param sale: browse_record(sale.order) + :returns: tuple(product_template_ids , product_category_ids) + """ + categ_ids = set() + prod_tmpl_ids = set() + for line in sale.order_line.filtered(lambda x: not x.display_type): + prod_tmpl_ids.add(line.product_id.product_tmpl_id.id) + categ = line.product_id.categ_id + while categ: + categ_ids.add(categ.id) + categ = categ.parent_id + return list(prod_tmpl_ids), list(categ_ids) + + def _compute_price_rule_global(self, sale): + """Compute the price for the given sale order + :param sale: browse_record(sale.order) + :returns: dict{sale_order_line_id: (price, suitable_rule) for the given pricelist} + """ + self.ensure_one() + date = sale.date_order + qty_data = { + "by_template": {}, + "by_categ": {}, + } + + for line in sale.order_line.filtered(lambda x: not x.display_type): + 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 + + prod_tmpl_ids, categ_ids = self._extract_products_and_categs_from_sale(sale) + + items = self._compute_price_rule_get_items_globals( + date, prod_tmpl_ids, categ_ids + ) + results = {} + for line in sale.order_line.filtered(lambda x: not x.display_type): + product = line.product_id + results[line.id] = 0.0 + suitable_rule = False + + # if Public user try to access standard price from website sale, + # need to call price_compute. + price = product.price_compute("list_price")[product.id] + + for rule in items: + if not rule._is_applicable_for_sale(product, qty_data): + continue + + if rule.base == "pricelist" and rule.base_pricelist_id: + # first, try compute the price for global rule + # otherwise, fallback to regular computation + # with qty from line instead of accumulated qty + ( + price, + rule_applied, + ) = rule.base_pricelist_id._compute_price_rule_global(sale)[line.id] + if not rule_applied: + price = rule.base_pricelist_id._compute_price_rule( + product, line.product_uom_qty + )[product.id][0] + src_currency = rule.base_pricelist_id.currency_id + else: + # if base option is public price take sale price else cost price of product + # price_compute returns the price in the context UoM, i.e. qty_uom_id + price = product.price_compute(rule.base)[product.id] + if rule.base == "standard_price": + src_currency = product.cost_currency_id + else: + src_currency = product.currency_id + + if src_currency != self.currency_id: + price = src_currency._convert( + price, self.currency_id, self.env.company, date, round=False + ) + if price is not False: + price = rule._compute_price( + product, + line.product_uom_qty, + product.uom_id, + date, + self.currency_id, + ) + suitable_rule = rule + break + + if not suitable_rule: + cur = product.currency_id + price = cur._convert( + price, self.currency_id, self.env.company, date, round=False + ) + + results[line.id] = (price, suitable_rule and suitable_rule.id or False) + + return results + + +class ProductPricelistItem(models.Model): + _inherit = "product.pricelist.item" + + applied_on = fields.Selection( + selection_add=[ + ("4_global_product_template", "Global - Product template"), + ("5_global_product_category", "Global - Product category"), + ], + ondelete={ + "4_global_product_template": "set default", + "5_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 == "5_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 == "4_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 == "5_global_product_category": + item.name = _("Global category: %s") % ( + item.global_categ_id.display_name + ) + elif ( + item.global_product_tmpl_id + and item.applied_on == "4_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 == "5_global_product_category": + values.update( + { + "product_id": None, + "product_tmpl_id": None, + "categ_id": None, + "global_product_tmpl_id": None, + } + ) + elif applied_on == "4_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 == "5_global_product_category": + values.update( + { + "product_id": None, + "product_tmpl_id": None, + "categ_id": None, + "global_product_tmpl_id": None, + } + ) + elif applied_on == "4_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_sale(self, product, qty_data): + """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() + is_applicable = True + if self.applied_on == "4_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 == "5_global_product_category": + rule_family_categories = get_family_category(self.global_categ_id).ids + rule_family_categories.append(self.global_categ_id.id) + + total_qty = 0 + for categ in qty_data["by_categ"]: + if categ.id in rule_family_categories: + total_qty += qty_data["by_categ"].get(categ, 0.0) + + if ( + self.min_quantity + and total_qty < self.min_quantity + or product.categ_id.id not in rule_family_categories + ): + 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..2da79740f4b --- /dev/null +++ b/sale_pricelist_global_rule/models/sale_order.py @@ -0,0 +1,88 @@ +import math + +from odoo import api, fields, models +from odoo.tools import float_compare + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + need_recompute_pricelist_global = fields.Boolean() + has_pricelist_global = fields.Boolean(compute="_compute_has_pricelist_global") + + @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 + ( + prod_tmpl_ids, + categ_ids, + ) = sale.pricelist_id._extract_products_and_categs_from_sale(sale) + items = sale.pricelist_id._compute_price_rule_get_items_globals( + sale.date_order, prod_tmpl_ids, categ_ids + ) + sale.has_pricelist_global = bool(items) + + @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() + prices_data = self.pricelist_id._compute_price_rule_global(self) + digits = self.pricelist_id.currency_id.decimal_places + is_discount_visible = ( + self.pricelist_id.discount_policy == "without_discount" + and self.env.user.has_group("product.group_discount_per_so_line") + ) + for line in self.order_line.filtered(lambda x: not x.display_type): + vals_to_write = {"discount": 0.0} + product = line.product_id.with_context( + lang=self.partner_id.lang, + partner=self.partner_id, + quantity=line.product_uom_qty, + date=self.date_order, + pricelist=self.pricelist_id.id, + uom=line.product_uom.id, + fiscal_position=self.env.context.get("fiscal_position"), + ) + price, suitable_rule = prices_data[line.id] + price = math.trunc(price * 100) / 100 + if is_discount_visible: + product_context = dict( + self.env.context, + partner_id=self.partner_id.id, + date=self.date_order, + uom=line.product_uom.id, + ) + + currency = self.pricelist_id.currency_id + base_price = line.with_context( + **product_context + ).product_id.uom_id._compute_price(product.lst_price, line.product_uom) + if base_price != 0: + if self.pricelist_id.currency_id != currency: + # we need new_list_price in the same currency as price, + # which is in the SO's pricelist's currency + base_price = currency._convert( + base_price, + self.pricelist_id.currency_id, + self.company_id or self.env.company, + self.date_order or fields.Date.context_today(self), + ) + discount = (base_price - price) / base_price * 100 + + if (discount > 0 and base_price > 0) or ( + discount < 0 and base_price < 0 + ): + vals_to_write["discount"] = discount + price = max(base_price, price) + + if float_compare(price, line.price_unit, precision_digits=digits) != 0: + vals_to_write["price_unit"] = price + if vals_to_write: + + line.write(vals_to_write) + self.need_recompute_pricelist_global = False diff --git a/sale_pricelist_global_rule/readme/CONFIGURE.rst b/sale_pricelist_global_rule/readme/CONFIGURE.rst new file mode 100644 index 00000000000..1368f0ef55d --- /dev/null +++ b/sale_pricelist_global_rule/readme/CONFIGURE.rst @@ -0,0 +1,4 @@ +- 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.rst b/sale_pricelist_global_rule/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..cacb8b24442 --- /dev/null +++ b/sale_pricelist_global_rule/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Tecnativa `_ + + * Pedro M. Baeza + * Carlos López diff --git a/sale_pricelist_global_rule/readme/DESCRIPTION.rst b/sale_pricelist_global_rule/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..657709ba24a --- /dev/null +++ b/sale_pricelist_global_rule/readme/DESCRIPTION.rst @@ -0,0 +1,19 @@ +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.rst b/sale_pricelist_global_rule/readme/ROADMAP.rst new file mode 100644 index 00000000000..d240dc09b4c --- /dev/null +++ b/sale_pricelist_global_rule/readme/ROADMAP.rst @@ -0,0 +1 @@ +- 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.rst b/sale_pricelist_global_rule/readme/USAGE.rst new file mode 100644 index 00000000000..27a37f9d56f --- /dev/null +++ b/sale_pricelist_global_rule/readme/USAGE.rst @@ -0,0 +1,4 @@ +- 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..19a46179c63 --- /dev/null +++ b/sale_pricelist_global_rule/static/description/index.html @@ -0,0 +1,469 @@ + + + + + +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..dca251e170a --- /dev/null +++ b/sale_pricelist_global_rule/tests/test_pricelist_global.py @@ -0,0 +1,778 @@ +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": "4_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": "5_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) + + 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) + + 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) + + 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) + + 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) + # 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) + # 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) + + 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": "4_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) + # 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) + + 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) + + 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) + + 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) + + 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) + + 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) + # 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) + # 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) + + 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": "5_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) + # 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) + + 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) + # 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) + # 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) + + 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) + # 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, 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) + # 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, 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) + + 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=100, discount=28 + - product_m_black: price=100, discount=28 + Case 5: + - Based on other pricelist + - Global pricelist discount policy: with_discount + - base pricelist discount_policy: without_discount + - product_m_red: price=72, discount=0 + - product_m_black: price=72, discount=0 + 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) + # 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) + # 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) + # 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, 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) + # 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) + # 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) 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..216efd4c3f6 --- /dev/null +++ b/sale_pricelist_global_rule/views/product_pricelist_item_views.xml @@ -0,0 +1,24 @@ + + + + + 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..930eed6fed2 --- /dev/null +++ b/sale_pricelist_global_rule/views/sale_order_views.xml @@ -0,0 +1,29 @@ + + + + + view.sale.order.form + sale.order + + + +