From b8b0edefce5f4dbc964ec1dd929ec6458796d487 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Tue, 21 Jan 2025 10:25:50 -0500 Subject: [PATCH] [ADD] product_list_price_from_pricelist: Automatically compute product list prices based on a pricelist. --- product_list_price_from_pricelist/README.rst | 119 +++++ product_list_price_from_pricelist/__init__.py | 1 + .../__manifest__.py | 14 + .../data/cron.xml | 15 + product_list_price_from_pricelist/i18n/es.po | 101 ++++ .../product_list_price_from_pricelist.pot | 92 ++++ .../models/__init__.py | 3 + .../models/product_pricelist.py | 142 ++++++ .../models/res_company.py | 20 + .../models/res_config_settings.py | 22 + .../readme/CONFIGURE.rst | 14 + .../readme/CONTRIBUTORS.rst | 4 + .../readme/DESCRIPTION.rst | 1 + .../readme/ROADMAP.rst | 2 + .../readme/USAGE.rst | 4 + .../static/description/index.html | 463 ++++++++++++++++++ .../tests/__init__.py | 1 + .../tests/test_pricelist.py | 363 ++++++++++++++ .../views/res_config_settings_views.xml | 66 +++ .../addons/product_list_price_from_pricelist | 1 + .../setup.py | 6 + 21 files changed, 1454 insertions(+) create mode 100644 product_list_price_from_pricelist/README.rst create mode 100644 product_list_price_from_pricelist/__init__.py create mode 100644 product_list_price_from_pricelist/__manifest__.py create mode 100644 product_list_price_from_pricelist/data/cron.xml create mode 100644 product_list_price_from_pricelist/i18n/es.po create mode 100644 product_list_price_from_pricelist/i18n/product_list_price_from_pricelist.pot create mode 100644 product_list_price_from_pricelist/models/__init__.py create mode 100644 product_list_price_from_pricelist/models/product_pricelist.py create mode 100644 product_list_price_from_pricelist/models/res_company.py create mode 100644 product_list_price_from_pricelist/models/res_config_settings.py create mode 100644 product_list_price_from_pricelist/readme/CONFIGURE.rst create mode 100644 product_list_price_from_pricelist/readme/CONTRIBUTORS.rst create mode 100644 product_list_price_from_pricelist/readme/DESCRIPTION.rst create mode 100644 product_list_price_from_pricelist/readme/ROADMAP.rst create mode 100644 product_list_price_from_pricelist/readme/USAGE.rst create mode 100644 product_list_price_from_pricelist/static/description/index.html create mode 100644 product_list_price_from_pricelist/tests/__init__.py create mode 100644 product_list_price_from_pricelist/tests/test_pricelist.py create mode 100644 product_list_price_from_pricelist/views/res_config_settings_views.xml create mode 120000 setup/product_list_price_from_pricelist/odoo/addons/product_list_price_from_pricelist create mode 100644 setup/product_list_price_from_pricelist/setup.py diff --git a/product_list_price_from_pricelist/README.rst b/product_list_price_from_pricelist/README.rst new file mode 100644 index 00000000000..40ef7cecbe6 --- /dev/null +++ b/product_list_price_from_pricelist/README.rst @@ -0,0 +1,119 @@ +============================================ +Compute product sales price from a pricelist +============================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1033e6116f2ea3d86c1b0a87d872a082b85b265a83f24fd660f9425936d37b6b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fproduct--attribute-lightgray.png?logo=github + :target: https://github.com/OCA/product-attribute/tree/16.0/product_list_price_from_pricelist + :alt: OCA/product-attribute +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-attribute-16-0/product-attribute-16-0-product_list_price_from_pricelist + :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/product-attribute&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module enables the automatic computation of a product's sale price based on the configuration of a pricelist. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +- Go to `Sales` -> `Products` -> `Pricelists`. +- Create a new pricelist and add at least one rule. +- Specify the product template or category for the rule. +- Set the `computation mode` and save + +**Note**: Ensure the minimum quantity is not great than 1 for the rule to apply effectively. + +- Go to `Sales` -> `Configuration` -> `Settings`. +- In the `Pricing` section, select the `Pricelist to compute sale price` created in the previous step. +- Optionally and only with a multi-company environment enabled, set the `Main company for compute sale price` to restrict the computation to a specific company. +- Save the configuration + +The module creates a cron job to update the product list price every day. The cron job is disabled by default. +To enable it, go to `Settings` -> `Technical` -> `Automation` -> `Scheduled Actions` and search for `Product sale price: Update price from pricelist`. + +Usage +===== + +**To update product prices according to the pricelist rules** + +- Stay in the settings configuration with the selected Pricelist. +- Click the **Update Product Prices** button to apply the rules and update the sale prices of all products. + +Known issues / Roadmap +====================== + +The `list_price` field is not `company-dependent`, meaning that if a product is shared across multiple companies, the same list price will apply to all of them. +To minimize errors, you can set a primary company to take precedence. This can be configured in the settings under the field `Main company for computing sale price`. + +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. + +.. |maintainer-carlos-lopez-tecnativa| image:: https://github.com/carlos-lopez-tecnativa.png?size=40px + :target: https://github.com/carlos-lopez-tecnativa + :alt: carlos-lopez-tecnativa + +Current `maintainer `__: + +|maintainer-carlos-lopez-tecnativa| + +This module is part of the `OCA/product-attribute `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/product_list_price_from_pricelist/__init__.py b/product_list_price_from_pricelist/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/product_list_price_from_pricelist/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_list_price_from_pricelist/__manifest__.py b/product_list_price_from_pricelist/__manifest__.py new file mode 100644 index 00000000000..7b6fec09a7e --- /dev/null +++ b/product_list_price_from_pricelist/__manifest__.py @@ -0,0 +1,14 @@ +{ + "name": "Compute product sales price from a pricelist", + "version": "16.0.1.0.0", + "author": "Tecnativa, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/product-attribute", + "depends": [ + "sale", + ], + "data": ["data/cron.xml", "views/res_config_settings_views.xml"], + "maintainers": ["carlos-lopez-tecnativa"], + "installable": True, + "auto_install": False, + "license": "AGPL-3", +} diff --git a/product_list_price_from_pricelist/data/cron.xml b/product_list_price_from_pricelist/data/cron.xml new file mode 100644 index 00000000000..40dfe33e5c4 --- /dev/null +++ b/product_list_price_from_pricelist/data/cron.xml @@ -0,0 +1,15 @@ + + + + Product sale price: Update price from pricelist + + code + model._cron_update_product_list_price() + + 1 + days + -1 + + + + diff --git a/product_list_price_from_pricelist/i18n/es.po b/product_list_price_from_pricelist/i18n/es.po new file mode 100644 index 00000000000..7948c502b66 --- /dev/null +++ b/product_list_price_from_pricelist/i18n/es.po @@ -0,0 +1,101 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_list_price_from_pricelist +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-01-23 17:33+0000\n" +"PO-Revision-Date: 2025-01-23 12:33-0500\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.5\n" + +#. module: product_list_price_from_pricelist +#: model_terms:ir.ui.view,arch_db:product_list_price_from_pricelist.view_res_config_settings_form +msgid "Are you sure you want to update the prices for all products?. This operations cannot be undone." +msgstr "" +"¿Estás seguro de actualizar el precio de venta en todos los productos?. Esta operación no puede revertirse." + +#. module: product_list_price_from_pricelist +#: model:ir.model,name:product_list_price_from_pricelist.model_res_company +msgid "Companies" +msgstr "Compañías" + +#. module: product_list_price_from_pricelist +#: model:ir.model,name:product_list_price_from_pricelist.model_res_config_settings +msgid "Config Settings" +msgstr "Ajustes de configuración" + +#. module: product_list_price_from_pricelist +#: model_terms:ir.ui.view,arch_db:product_list_price_from_pricelist.view_res_config_settings_form +msgid "" +"If set, prices will be computed only if the company in the product matches the company specified here or is " +"empty.\n" +" Otherwise, prices will be computed based on the current company." +msgstr "" +"Si está configurada, los precios serán calculados solo si la compañía coincide con la compañía del producto " +"(o si esta vacía).\n" +" De lo contrario, los precios se calcularan basados en la compañía actual." + +#. module: product_list_price_from_pricelist +#: model:ir.model.fields,field_description:product_list_price_from_pricelist.field_res_config_settings__main_company_compute_price_id +msgid "Main company for compute sale price" +msgstr "Compañía principal para calcular precios de venta" + +#. module: product_list_price_from_pricelist +#: model:ir.model,name:product_list_price_from_pricelist.model_product_pricelist +msgid "Pricelist" +msgstr "Lista de precios" + +#. module: product_list_price_from_pricelist +#: model:ir.model,name:product_list_price_from_pricelist.model_product_pricelist_item +msgid "Pricelist Rule" +msgstr "Regla de la lista de precios" + +#. module: product_list_price_from_pricelist +#: model:ir.model.fields,help:product_list_price_from_pricelist.field_res_company__base_pricelist_compute_price_id +#: model:ir.model.fields,help:product_list_price_from_pricelist.field_res_config_settings__base_pricelist_compute_price_id +msgid "Pricelist used to calculate the price of all products" +msgstr "Lista de precio usada para calcular el precio de venta de todos los productos" + +#. module: product_list_price_from_pricelist +#: model:ir.actions.server,name:product_list_price_from_pricelist.ir_cron_update_product_sale_price_ir_actions_server +#: model:ir.cron,cron_name:product_list_price_from_pricelist.ir_cron_update_product_sale_price +msgid "Product sale price: Update price from pricelist" +msgstr "Precio de venta de productos: Actualizar precio desde tarifas de venta" + +#. module: product_list_price_from_pricelist +#: model:ir.model.fields,field_description:product_list_price_from_pricelist.field_res_company__base_pricelist_compute_price_id +#: model:ir.model.fields,field_description:product_list_price_from_pricelist.field_res_config_settings__base_pricelist_compute_price_id +msgid "Recomputing pricelist" +msgstr "Lista de precio para calcular precio de venta" + +#. module: product_list_price_from_pricelist +#: model_terms:ir.ui.view,arch_db:product_list_price_from_pricelist.view_res_config_settings_form +msgid "" +"Set the base pricelist to compute the sales price for all the products.\n" +"
" +msgstr "" +"Configure la tarifa de precios base para calcular el precio de venta de los productos.\n" +"
" + +#. module: product_list_price_from_pricelist +#: model_terms:ir.ui.view,arch_db:product_list_price_from_pricelist.view_res_config_settings_form +msgid "Update product prices" +msgstr "Actualizar precio de venta en productos" + +#. module: product_list_price_from_pricelist +#: model_terms:ir.ui.view,arch_db:product_list_price_from_pricelist.view_res_config_settings_form +msgid "" +"WARNING: Prices are always computed for a quantity of 1, so rules with a minimum quantity higher than that " +"won't be taken into account" +msgstr "" +"ADVERTENCIA: Los precios siempre se calculan para la cantidad de 1, las reglas con cantidad mínima mayor a " +"esto no serán tomadas en cuenta" diff --git a/product_list_price_from_pricelist/i18n/product_list_price_from_pricelist.pot b/product_list_price_from_pricelist/i18n/product_list_price_from_pricelist.pot new file mode 100644 index 00000000000..a5ff6d380d4 --- /dev/null +++ b/product_list_price_from_pricelist/i18n/product_list_price_from_pricelist.pot @@ -0,0 +1,92 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_list_price_from_pricelist +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-01-23 17:32+0000\n" +"PO-Revision-Date: 2025-01-23 17:32+0000\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: product_list_price_from_pricelist +#: model_terms:ir.ui.view,arch_db:product_list_price_from_pricelist.view_res_config_settings_form +msgid "" +"Are you sure you want to update the prices for all products?. This " +"operations cannot be undone." +msgstr "" + +#. module: product_list_price_from_pricelist +#: model:ir.model,name:product_list_price_from_pricelist.model_res_company +msgid "Companies" +msgstr "" + +#. module: product_list_price_from_pricelist +#: model:ir.model,name:product_list_price_from_pricelist.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: product_list_price_from_pricelist +#: model_terms:ir.ui.view,arch_db:product_list_price_from_pricelist.view_res_config_settings_form +msgid "" +"If set, prices will be computed only if the company in the product matches the company specified here or is empty.\n" +" Otherwise, prices will be computed based on the current company." +msgstr "" + +#. module: product_list_price_from_pricelist +#: model:ir.model.fields,field_description:product_list_price_from_pricelist.field_res_config_settings__main_company_compute_price_id +msgid "Main company for compute sale price" +msgstr "" + +#. module: product_list_price_from_pricelist +#: model:ir.model,name:product_list_price_from_pricelist.model_product_pricelist +msgid "Pricelist" +msgstr "" + +#. module: product_list_price_from_pricelist +#: model:ir.model,name:product_list_price_from_pricelist.model_product_pricelist_item +msgid "Pricelist Rule" +msgstr "" + +#. module: product_list_price_from_pricelist +#: model:ir.model.fields,help:product_list_price_from_pricelist.field_res_company__base_pricelist_compute_price_id +#: model:ir.model.fields,help:product_list_price_from_pricelist.field_res_config_settings__base_pricelist_compute_price_id +msgid "Pricelist used to calculate the price of all products" +msgstr "" + +#. module: product_list_price_from_pricelist +#: model:ir.actions.server,name:product_list_price_from_pricelist.ir_cron_update_product_sale_price_ir_actions_server +#: model:ir.cron,cron_name:product_list_price_from_pricelist.ir_cron_update_product_sale_price +msgid "Product sale price: Update price from pricelist" +msgstr "" + +#. module: product_list_price_from_pricelist +#: model:ir.model.fields,field_description:product_list_price_from_pricelist.field_res_company__base_pricelist_compute_price_id +#: model:ir.model.fields,field_description:product_list_price_from_pricelist.field_res_config_settings__base_pricelist_compute_price_id +msgid "Recomputing pricelist" +msgstr "" + +#. module: product_list_price_from_pricelist +#: model_terms:ir.ui.view,arch_db:product_list_price_from_pricelist.view_res_config_settings_form +msgid "" +"Set the base pricelist to compute the sales price for all the products.\n" +"
" +msgstr "" + +#. module: product_list_price_from_pricelist +#: model_terms:ir.ui.view,arch_db:product_list_price_from_pricelist.view_res_config_settings_form +msgid "Update product prices" +msgstr "" + +#. module: product_list_price_from_pricelist +#: model_terms:ir.ui.view,arch_db:product_list_price_from_pricelist.view_res_config_settings_form +msgid "" +"WARNING: Prices are always computed for a quantity of 1, so rules with a " +"minimum quantity higher than that won't be taken into account" +msgstr "" diff --git a/product_list_price_from_pricelist/models/__init__.py b/product_list_price_from_pricelist/models/__init__.py new file mode 100644 index 00000000000..05af701063a --- /dev/null +++ b/product_list_price_from_pricelist/models/__init__.py @@ -0,0 +1,3 @@ +from . import res_company +from . import res_config_settings +from . import product_pricelist diff --git a/product_list_price_from_pricelist/models/product_pricelist.py b/product_list_price_from_pricelist/models/product_pricelist.py new file mode 100644 index 00000000000..c61a5609c38 --- /dev/null +++ b/product_list_price_from_pricelist/models/product_pricelist.py @@ -0,0 +1,142 @@ +from odoo import api, models +from odoo.tools import config + + +class ProductPricelist(models.Model): + _inherit = "product.pricelist" + + def _update_product_price_from_pricelist(self, pricelist_items=None): + self.ensure_one() + all_templates = self.env["product.template"] + if not pricelist_items: + pricelist_items = self.item_ids + for item in pricelist_items: + all_templates |= item._get_all_templates_from_pricelist_item() + if all_templates: + pricelist_data = self._compute_price_rule(all_templates, 1) + for template in all_templates: + new_price, suitable_rule = pricelist_data[template.id] + if suitable_rule and new_price != template.list_price: + template.write({"list_price": new_price}) + return True + + def _get_domain_applicability_for_company(self): + """Return the domain to check if the pricelist is applicable for the company.""" + self.ensure_one() + main_company = self._get_main_company_to_compute_prices() + domain = [ + ("base_pricelist_compute_price_id", "=", self.id), + ("id", "=", main_company.id), + ] + return domain + + def _get_main_company_to_compute_prices(self): + """:return: Recordset of res.company""" + main_company_id = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("main_company_compute_price_id", "") + ) + if main_company_id: + return self.env["res.company"].browse(int(main_company_id)) + return self.company_id or self.env.company + + +class ProductPricelistItem(models.Model): + _inherit = "product.pricelist.item" + + @api.model_create_multi + def create(self, vals_list): + new_pricelist_items = super().create(vals_list) + test_condition = not config["test_enable"] or ( + config["test_enable"] + and self.env.context.get("test_compute_list_price_from_pricelist") + ) + # recompute the product's sales price. + if test_condition: + for pricelist in new_pricelist_items.pricelist_id: + pricelist._update_product_price_from_pricelist(new_pricelist_items) + return new_pricelist_items + + def write(self, vals): + res = super().write(vals) + test_condition = not config["test_enable"] or ( + config["test_enable"] + and self.env.context.get("test_compute_list_price_from_pricelist") + ) + # If any field from the expected ones is changed, + # recompute the product's sales price. + if test_condition and set(vals.keys()).intersection( + self._get_fields_to_recompute_product_list_price() + ): + for pricelist in self.pricelist_id: + pricelist._update_product_price_from_pricelist(self) + return res + + @api.model + def _get_fields_to_recompute_product_list_price(self): + """Return the list of fields that will trigger the + recomputation of the products list price. + :return: list(str) + """ + fields_triggers = [ + "applied_on", + "base", + "base_pricelist_id", + "categ_id", + "compute_price", + "date_start", + "date_end", + "fixed_price", + "percent_price", + "price_discount", + "price_surcharge", + "price_round", + "product_tmpl_id", + "product_id", + ] + return fields_triggers + + def _get_all_templates_from_pricelist_item(self): + """Returns the products template + affected by the pricelist item that require recomputation. + :return: Recordset of product.template""" + self.ensure_one() + templates = self.env["product.template"] + company = self.pricelist_id._get_main_company_to_compute_prices() + is_pricelist_available = bool( + self.env["res.company"].search_count( + self.pricelist_id._get_domain_applicability_for_company() + ) + ) + if not is_pricelist_available or ( + self.pricelist_id.company_id + and self.pricelist_id.company_id.id != company.id + ): + return self.env["product.template"] # empty recordset + domain_company = [("company_id", "in", [False, company.id])] + if self.applied_on == "3_global": + templates = self.env["product.template"].search(domain_company) + elif self.applied_on == "2_product_category" and self.categ_id: + templates = self.env["product.template"].search( + [("categ_id", "=", self.categ_id.id)] + domain_company + ) + elif ( + self.applied_on == "1_product" + and self.product_tmpl_id + and ( + not self.product_tmpl_id.company_id + or self.product_tmpl_id.company_id.id == company.id + ) + ): + templates = self.product_tmpl_id + elif ( + self.applied_on == "0_product_variant" + and self.product_id + and ( + not self.product_id.company_id + or self.product_id.company_id.id == company.id + ) + ): + templates = self.product_id.product_tmpl_id + return templates diff --git a/product_list_price_from_pricelist/models/res_company.py b/product_list_price_from_pricelist/models/res_company.py new file mode 100644 index 00000000000..cf8d716ef9f --- /dev/null +++ b/product_list_price_from_pricelist/models/res_company.py @@ -0,0 +1,20 @@ +from odoo import api, fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + base_pricelist_compute_price_id = fields.Many2one( + "product.pricelist", + string="Recomputing pricelist", + help="Pricelist used to calculate the price of all products", + ) + + @api.model + def _cron_update_product_list_price(self): + companies = self.search([("base_pricelist_compute_price_id", "!=", False)]) + for company in companies: + company.base_pricelist_compute_price_id.with_company( + company + )._update_product_price_from_pricelist() + return True diff --git a/product_list_price_from_pricelist/models/res_config_settings.py b/product_list_price_from_pricelist/models/res_config_settings.py new file mode 100644 index 00000000000..a1555e4c2b7 --- /dev/null +++ b/product_list_price_from_pricelist/models/res_config_settings.py @@ -0,0 +1,22 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + base_pricelist_compute_price_id = fields.Many2one( + "product.pricelist", + related="company_id.base_pricelist_compute_price_id", + readonly=False, + ) + main_company_compute_price_id = fields.Many2one( + "res.company", + config_parameter="main_company_compute_price_id", + string="Main company for compute sale price", + readonly=False, + ) + + def action_update_product_price_from_pricelist(self): + self.ensure_one() + pricelist = self.company_id.base_pricelist_compute_price_id + pricelist._update_product_price_from_pricelist() diff --git a/product_list_price_from_pricelist/readme/CONFIGURE.rst b/product_list_price_from_pricelist/readme/CONFIGURE.rst new file mode 100644 index 00000000000..474d10fb05c --- /dev/null +++ b/product_list_price_from_pricelist/readme/CONFIGURE.rst @@ -0,0 +1,14 @@ +- Go to `Sales` -> `Products` -> `Pricelists`. +- Create a new pricelist and add at least one rule. +- Specify the product template or category for the rule. +- Set the `computation mode` and save + +**Note**: Ensure the minimum quantity is not great than 1 for the rule to apply effectively. + +- Go to `Sales` -> `Configuration` -> `Settings`. +- In the `Pricing` section, select the `Pricelist to compute sale price` created in the previous step. +- Optionally and only with a multi-company environment enabled, set the `Main company for compute sale price` to restrict the computation to a specific company. +- Save the configuration + +The module creates a cron job to update the product list price every day. The cron job is disabled by default. +To enable it, go to `Settings` -> `Technical` -> `Automation` -> `Scheduled Actions` and search for `Product sale price: Update price from pricelist`. \ No newline at end of file diff --git a/product_list_price_from_pricelist/readme/CONTRIBUTORS.rst b/product_list_price_from_pricelist/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..29e931a17d4 --- /dev/null +++ b/product_list_price_from_pricelist/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Tecnativa `_ + + * Pedro M. Baeza + * Carlos López \ No newline at end of file diff --git a/product_list_price_from_pricelist/readme/DESCRIPTION.rst b/product_list_price_from_pricelist/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..7532b365c4b --- /dev/null +++ b/product_list_price_from_pricelist/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This module enables the automatic computation of a product's sale price based on the configuration of a pricelist. \ No newline at end of file diff --git a/product_list_price_from_pricelist/readme/ROADMAP.rst b/product_list_price_from_pricelist/readme/ROADMAP.rst new file mode 100644 index 00000000000..ead3062524c --- /dev/null +++ b/product_list_price_from_pricelist/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +The `list_price` field is not `company-dependent`, meaning that if a product is shared across multiple companies, the same list price will apply to all of them. +To minimize errors, you can set a primary company to take precedence. This can be configured in the settings under the field `Main company for computing sale price`. \ No newline at end of file diff --git a/product_list_price_from_pricelist/readme/USAGE.rst b/product_list_price_from_pricelist/readme/USAGE.rst new file mode 100644 index 00000000000..5ce4cf695e4 --- /dev/null +++ b/product_list_price_from_pricelist/readme/USAGE.rst @@ -0,0 +1,4 @@ +**To update product prices according to the pricelist rules** + +- Stay in the settings configuration with the selected Pricelist. +- Click the **Update Product Prices** button to apply the rules and update the sale prices of all products. \ No newline at end of file diff --git a/product_list_price_from_pricelist/static/description/index.html b/product_list_price_from_pricelist/static/description/index.html new file mode 100644 index 00000000000..f0c8664ac57 --- /dev/null +++ b/product_list_price_from_pricelist/static/description/index.html @@ -0,0 +1,463 @@ + + + + + +Compute product sales price from a pricelist + + + +
+

Compute product sales price from a pricelist

+ + +

Beta License: AGPL-3 OCA/product-attribute Translate me on Weblate Try me on Runboat

+

This module enables the automatic computation of a product’s sale price based on the configuration of a pricelist.

+

Table of contents

+ +
+

Configuration

+
    +
  • Go to Sales -> Products -> Pricelists.
  • +
  • Create a new pricelist and add at least one rule.
  • +
  • Specify the product template or category for the rule.
  • +
  • Set the computation mode and save
  • +
+

Note: Ensure the minimum quantity is not great than 1 for the rule to apply effectively.

+
    +
  • Go to Sales -> Configuration -> Settings.
  • +
  • In the Pricing section, select the Pricelist to compute sale price created in the previous step.
  • +
  • Optionally and only with a multi-company environment enabled, set the Main company for compute sale price to restrict the computation to a specific company.
  • +
  • Save the configuration
  • +
+

The module creates a cron job to update the product list price every day. The cron job is disabled by default. +To enable it, go to Settings -> Technical -> Automation -> Scheduled Actions and search for Product sale price: Update price from pricelist.

+
+
+

Usage

+

To update product prices according to the pricelist rules

+
    +
  • Stay in the settings configuration with the selected Pricelist.
  • +
  • Click the Update Product Prices button to apply the rules and update the sale prices of all products.
  • +
+
+
+

Known issues / Roadmap

+

The list_price field is not company-dependent, meaning that if a product is shared across multiple companies, the same list price will apply to all of them. +To minimize errors, you can set a primary company to take precedence. This can be configured in the settings under the field Main company for computing sale price.

+
+
+

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.

+

Current maintainer:

+

carlos-lopez-tecnativa

+

This module is part of the OCA/product-attribute project on GitHub.

+

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

+
+
+
+ + diff --git a/product_list_price_from_pricelist/tests/__init__.py b/product_list_price_from_pricelist/tests/__init__.py new file mode 100644 index 00000000000..61e09273836 --- /dev/null +++ b/product_list_price_from_pricelist/tests/__init__.py @@ -0,0 +1 @@ +from . import test_pricelist diff --git a/product_list_price_from_pricelist/tests/test_pricelist.py b/product_list_price_from_pricelist/tests/test_pricelist.py new file mode 100644 index 00000000000..78aa6865e1f --- /dev/null +++ b/product_list_price_from_pricelist/tests/test_pricelist.py @@ -0,0 +1,363 @@ +from odoo.tools import float_compare + +from odoo.addons.base.tests.common import TransactionCase + + +class TestPricelistGlobal(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict(cls.env.context, test_compute_list_price_from_pricelist=True) + ) + cls.Product = cls.env["product.product"] + cls.ProductTemplate = cls.env["product.template"] + cls.ProductCateg = cls.env["product.category"] + cls.Pricelist = cls.env["product.pricelist"] + cls.PricelistItem = cls.env["product.pricelist.item"] + cls.company_2 = cls.env["res.company"].create({"name": "Company 2"}) + cls.categ_1 = cls.ProductCateg.create({"name": "Categ 1"}) + cls.categ_2 = cls.ProductCateg.create({"name": "Categ 2"}) + cls.product_1 = cls.Product.create( + { + "name": "Product 1", + "list_price": 100, + "standard_price": 80, + "categ_id": cls.categ_1.id, + } + ) + cls.product_2 = cls.Product.create( + { + "name": "Product 2", + "list_price": 200, + "standard_price": 180, + "categ_id": cls.categ_1.id, + } + ) + cls.product_3 = cls.Product.create( + { + "name": "Product 3", + "list_price": 300, + "standard_price": 280, + "categ_id": cls.categ_2.id, + } + ) + # this product is not affected by the pricelist + # the price should remain unchanged + cls.product_4 = cls.Product.create( + { + "name": "Product 4", + "list_price": 400, + "categ_id": cls.categ_2.id, + } + ) + # this product just belongs to company 2 + cls.product_5 = cls.Product.create( + { + "name": "Product 4", + "list_price": 500, + "categ_id": cls.categ_2.id, + "company_id": cls.company_2.id, + } + ) + cls.base_pricelist = cls.Pricelist.create({"name": "Base Pricelist"}) + cls.base_pricelist_item_global = cls.PricelistItem.create( + { + "pricelist_id": cls.base_pricelist.id, + "applied_on": "3_global", + "compute_price": "percentage", + "percent_price": -5, + } + ) + cls.base_pricelist_item_product_3 = cls.PricelistItem.create( + { + "pricelist_id": cls.base_pricelist.id, + "applied_on": "0_product_variant", + "product_id": cls.product_3.id, + "compute_price": "percentage", + "percent_price": -10, + } + ) + cls.pricelist = cls.Pricelist.create({"name": "Pricelist"}) + cls.pricelist_item_by_product = cls.PricelistItem.create( + { + "pricelist_id": cls.pricelist.id, + "applied_on": "0_product_variant", + "product_id": cls.product_3.id, + "compute_price": "percentage", + "percent_price": 10, + } + ) + cls.pricelist_item_by_categ = cls.PricelistItem.create( + { + "pricelist_id": cls.pricelist.id, + "applied_on": "2_product_category", + "categ_id": cls.categ_1.id, + "compute_price": "percentage", + "percent_price": 20, + } + ) + # this pricelist is for company 2 + # and just affects the product_4, product_5 and categ_1(products 1 and 2) + cls.pricelist_c2 = cls.Pricelist.create( + {"name": "Pricelist C2", "company_id": cls.company_2.id} + ) + cls.pricelist_item_by_product4_c2 = cls.PricelistItem.create( + { + "pricelist_id": cls.pricelist_c2.id, + "applied_on": "1_product", + "product_tmpl_id": cls.product_4.product_tmpl_id.id, + "compute_price": "percentage", + "percent_price": -10, + } + ) + cls.pricelist_item_by_product5_c2 = cls.PricelistItem.create( + { + "pricelist_id": cls.pricelist_c2.id, + "applied_on": "1_product", + "product_tmpl_id": cls.product_5.product_tmpl_id.id, + "compute_price": "percentage", + "percent_price": -10, + } + ) + cls.pricelist_item_by_categ_c2 = cls.PricelistItem.create( + { + "pricelist_id": cls.pricelist_c2.id, + "applied_on": "2_product_category", + "categ_id": cls.categ_1.id, + "compute_price": "percentage", + "percent_price": -5, + } + ) + cls.env.company.base_pricelist_compute_price_id = cls.pricelist + cls.company_2.base_pricelist_compute_price_id = cls.pricelist_c2 + + def test_02_pricelist_compute_price_percentage_with_discount(self): + self.pricelist._update_product_price_from_pricelist() + self.assertEqual(self.product_1.list_price, 80) + self.assertEqual(self.product_2.list_price, 160) + self.assertEqual(self.product_3.list_price, 270) + self.assertEqual(self.product_4.list_price, 400) + self.assertEqual(self.product_5.list_price, 500) + + def test_03_pricelist_compute_price_percentage_with_recharge(self): + self.pricelist_item_by_product.write({"percent_price": -10}) + self.pricelist_item_by_categ.write({"percent_price": -20}) + self.assertEqual(self.product_1.list_price, 120) + self.assertEqual(self.product_2.list_price, 240) + self.assertEqual(self.product_3.list_price, 330) + self.assertEqual(self.product_4.list_price, 400) + self.assertEqual(self.product_5.list_price, 500) + + def test_04_pricelist_compute_price_fixed(self): + self.pricelist_item_by_product.write( + {"compute_price": "fixed", "fixed_price": 150} + ) + self.pricelist_item_by_categ.write( + {"compute_price": "fixed", "fixed_price": 250} + ) + self.assertEqual(self.product_1.list_price, 250) + self.assertEqual(self.product_2.list_price, 250) + self.assertEqual(self.product_3.list_price, 150) + self.assertEqual(self.product_4.list_price, 400) + self.assertEqual(self.product_5.list_price, 500) + + def test_05_pricelist_compute_price_formula(self): + self.pricelist_item_by_product.write( + {"compute_price": "formula", "price_discount": -10} + ) + self.pricelist_item_by_categ.write( + {"compute_price": "formula", "price_discount": -20} + ) + self.assertEqual(self.product_1.list_price, 120) + self.assertEqual(self.product_2.list_price, 240) + self.assertEqual(self.product_3.list_price, 330) + self.assertEqual(self.product_4.list_price, 400) + self.assertEqual(self.product_5.list_price, 500) + + def test_06_pricelist_compute_price_formula_round(self): + self.pricelist_item_by_product.write( + { + "compute_price": "formula", + "price_discount": -10, + "price_round": 10, + "price_surcharge": -0.01, + } + ) + self.pricelist_item_by_categ.write( + { + "compute_price": "formula", + "price_discount": -20, + "price_round": 10, + "price_surcharge": -0.01, + } + ) + self.assertEqual(float_compare(self.product_1.list_price, 119.99, 2), 0) + self.assertEqual(float_compare(self.product_2.list_price, 239.99, 2), 0) + self.assertEqual(float_compare(self.product_3.list_price, 329.99, 2), 0) + self.assertEqual(self.product_4.list_price, 400) + self.assertEqual(self.product_5.list_price, 500) + + def test_06_pricelist_compute_price_formula_cost(self): + self.pricelist_item_by_product.write( + { + "compute_price": "formula", + "price_discount": -10, + "base": "standard_price", + } + ) + self.pricelist_item_by_categ.write( + { + "compute_price": "formula", + "price_discount": -20, + "base": "standard_price", + } + ) + self.assertEqual(self.product_1.list_price, 96) + self.assertEqual(self.product_2.list_price, 216) + self.assertEqual(self.product_3.list_price, 308) + self.assertEqual(self.product_4.list_price, 400) + self.assertEqual(self.product_5.list_price, 500) + + def test_06_pricelist_compute_price_formula_other_pricelist(self): + self.pricelist_item_by_product.write( + { + "compute_price": "formula", + "price_discount": -10, + "base": "pricelist", + "base_pricelist_id": self.base_pricelist.id, + } + ) + self.pricelist_item_by_categ.write( + { + "compute_price": "formula", + "price_discount": -20, + "base": "pricelist", + "base_pricelist_id": self.base_pricelist.id, + } + ) + self.assertEqual(self.product_1.list_price, 126) + self.assertEqual(self.product_2.list_price, 252) + self.assertEqual(self.product_3.list_price, 363) + self.assertEqual(self.product_4.list_price, 400) + self.assertEqual(self.product_5.list_price, 500) + + def test_06_pricelist_compute_price_automatically(self): + # change fields that trigger recomputation + self.pricelist_item_by_product.write( + { + "compute_price": "formula", + "price_discount": -10, + "base": "pricelist", + "base_pricelist_id": self.base_pricelist.id, + } + ) + self.pricelist_item_by_categ.write( + { + "compute_price": "formula", + "price_discount": -20, + "base": "pricelist", + "base_pricelist_id": self.base_pricelist.id, + } + ) + self.assertEqual(self.product_1.list_price, 126) + self.assertEqual(self.product_2.list_price, 252) + self.assertEqual(self.product_3.list_price, 363) + self.assertEqual(self.product_4.list_price, 400) + self.assertEqual(self.product_5.list_price, 500) + # Change fields that do not trigger recomputation + # the prices should remain unchanged. + self.pricelist_item_by_product.write( + { + "price_max_margin": 100, + "price_min_margin": 1, + } + ) + self.pricelist_item_by_categ.write( + { + "price_max_margin": 200, + "price_min_margin": 2, + } + ) + self.assertEqual(self.product_1.list_price, 126) + self.assertEqual(self.product_2.list_price, 252) + self.assertEqual(self.product_3.list_price, 363) + self.assertEqual(self.product_4.list_price, 400) + self.assertEqual(self.product_5.list_price, 500) + + def test_07_pricelist_global(self): + """ + product_1 and product_2: Apply a 20% surcharge. + product_3: Apply a 10% surcharge. + product_4: Apply a 5% surcharge globally. + """ + self.pricelist_item_by_categ.write({"percent_price": -20}) + self.pricelist_item_by_product.write({"percent_price": -10}) + self.assertEqual(self.product_1.list_price, 120) + self.assertEqual(self.product_2.list_price, 240) + self.assertEqual(self.product_3.list_price, 330) + self.assertEqual(self.product_4.list_price, 400) # no rule applied + self.assertEqual(self.product_5.list_price, 500) # no rule applied + # create a new pricelist item global + # and the all prices should be recomputed automatically for all products + # according to the rules, but this pricelist triggers the recomputation + self.PricelistItem.create( + { + "pricelist_id": self.pricelist.id, + "applied_on": "3_global", + "compute_price": "percentage", + "percent_price": -5, + } + ) + self.assertEqual(self.product_1.list_price, 144) + self.assertEqual(self.product_2.list_price, 288) + self.assertEqual(self.product_3.list_price, 363) + self.assertEqual(self.product_4.list_price, 420) + self.assertEqual(self.product_5.list_price, 500) + + def test_08_pricelist_min_quantity(self): + # min_quantity > 1 should not apply the rule + self.assertEqual(self.product_3.list_price, 300) + self.pricelist_item_by_product.write({"percent_price": -10, "min_quantity": 2}) + self.assertEqual(self.product_3.list_price, 300) + + def test_08_pricelist_multicompany(self): + """ + In C1: + product_1 and product_2: Apply a 20% surcharge. + product_3: Apply a 10% surcharge. + product_4: Not affected by the pricelist in C1. + product_5: Not affected by the pricelist in C1. + In C2: + product_1 and product_2: Apply a 5% surcharge. + product_3: Not affected by the pricelist. + product_4: Apply a 10% surcharge. + product_5: Apply a 10% surcharge. + """ + self.pricelist_item_by_categ.write({"percent_price": -20}) + self.pricelist_item_by_product.write({"percent_price": -10}) + self.assertEqual(self.product_1.list_price, 120) + self.assertEqual(self.product_2.list_price, 240) + self.assertEqual(self.product_3.list_price, 330) + self.assertEqual(self.product_4.list_price, 400) + self.assertEqual(self.product_5.list_price, 500) + self.env["ir.config_parameter"].set_param( + "main_company_compute_price_id", self.company_2.id + ) + self.pricelist_c2._update_product_price_from_pricelist() + self.assertEqual(self.product_1.list_price, 126) + self.assertEqual(self.product_2.list_price, 252) + self.assertEqual(self.product_3.list_price, 330) + self.assertEqual(self.product_4.list_price, 440) + self.assertEqual(self.product_5.list_price, 550) + # Attempt to compute prices for the pricelist of C1 + # (which does not have a company set, so it uses the environment's company (C2)) + # the prices should remain unchanged. + self.pricelist.with_company( + self.company_2 + )._update_product_price_from_pricelist() + self.assertEqual(self.product_1.list_price, 126) + self.assertEqual(self.product_2.list_price, 252) + self.assertEqual(self.product_3.list_price, 330) + self.assertEqual(self.product_4.list_price, 440) + self.assertEqual(self.product_5.list_price, 550) diff --git a/product_list_price_from_pricelist/views/res_config_settings_views.xml b/product_list_price_from_pricelist/views/res_config_settings_views.xml new file mode 100644 index 00000000000..31cabdd3207 --- /dev/null +++ b/product_list_price_from_pricelist/views/res_config_settings_views.xml @@ -0,0 +1,66 @@ + + + + + view.res.config.settings.form + res.config.settings + + + +
+
+
+
+
+ + + + + diff --git a/setup/product_list_price_from_pricelist/odoo/addons/product_list_price_from_pricelist b/setup/product_list_price_from_pricelist/odoo/addons/product_list_price_from_pricelist new file mode 120000 index 00000000000..da3d1f578dd --- /dev/null +++ b/setup/product_list_price_from_pricelist/odoo/addons/product_list_price_from_pricelist @@ -0,0 +1 @@ +../../../../product_list_price_from_pricelist \ No newline at end of file diff --git a/setup/product_list_price_from_pricelist/setup.py b/setup/product_list_price_from_pricelist/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/product_list_price_from_pricelist/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)