From b8b0edefce5f4dbc964ec1dd929ec6458796d487 Mon Sep 17 00:00:00 2001 From: Carlos Lopez <carlos.lopez@tecnativa.com> 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 <https://github.com/OCA/product-attribute/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 <https://github.com/OCA/product-attribute/issues/new?body=module:%20product_list_price_from_pricelist%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Tecnativa + +Contributors +~~~~~~~~~~~~ + +* `Tecnativa <https://www.tecnativa.com>`_ + + * 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 <https://odoo-community.org/page/maintainer-role>`__: + +|maintainer-carlos-lopez-tecnativa| + +This module is part of the `OCA/product-attribute <https://github.com/OCA/product-attribute/tree/16.0/product_list_price_from_pricelist>`_ 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 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo noupdate="1"> + <record forcecreate="True" id="ir_cron_update_product_sale_price" model="ir.cron"> + <field name="name">Product sale price: Update price from pricelist</field> + <field name="model_id" ref="base.model_res_company" /> + <field name="state">code</field> + <field name="code">model._cron_update_product_list_price()</field> + <field name="user_id" ref="base.user_root" /> + <field name="interval_number">1</field> + <field name="interval_type">days</field> + <field name="numbercall">-1</field> + <field name="active" eval="False" /> + <field name="doall" eval="False" /> + </record> +</odoo> 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" +" <br/>" +msgstr "" +"Configure la tarifa de precios base para calcular el precio de venta de los productos.\n" +" <br/>" + +#. 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" +" <br/>" +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 <https://www.tecnativa.com>`_ + + * 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 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> +<head> +<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> +<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" /> +<title>Compute product sales price from a pricelist</title> +<style type="text/css"> + +/* +:Author: David Goodger (goodger@python.org) +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ +:Copyright: This stylesheet has been placed in the public domain. + +Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. + +See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to +customize this style sheet. +*/ + +/* used to remove borders from tables and images */ +.borderless, table.borderless td, table.borderless th { + border: 0 } + +table.borderless td, table.borderless th { + /* Override padding for "table.docutils td" with "! important". + The right padding separates the table cells. */ + padding: 0 0.5em 0 0 ! important } + +.first { + /* Override more specific margin styles with "! important". */ + margin-top: 0 ! important } + +.last, .with-subtitle { + margin-bottom: 0 ! important } + +.hidden { + display: none } + +.subscript { + vertical-align: sub; + font-size: smaller } + +.superscript { + vertical-align: super; + font-size: smaller } + +a.toc-backref { + text-decoration: none ; + color: black } + +blockquote.epigraph { + margin: 2em 5em ; } + +dl.docutils dd { + margin-bottom: 0.5em } + +object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] { + overflow: hidden; +} + +/* Uncomment (and remove this text!) to get bold-faced definition list terms +dl.docutils dt { + font-weight: bold } +*/ + +div.abstract { + margin: 2em 5em } + +div.abstract p.topic-title { + font-weight: bold ; + text-align: center } + +div.admonition, div.attention, div.caution, div.danger, div.error, +div.hint, div.important, div.note, div.tip, div.warning { + margin: 2em ; + border: medium outset ; + padding: 1em } + +div.admonition p.admonition-title, div.hint p.admonition-title, +div.important p.admonition-title, div.note p.admonition-title, +div.tip p.admonition-title { + font-weight: bold ; + font-family: sans-serif } + +div.attention p.admonition-title, div.caution p.admonition-title, +div.danger p.admonition-title, div.error p.admonition-title, +div.warning p.admonition-title, .code .error { + color: red ; + font-weight: bold ; + font-family: sans-serif } + +/* Uncomment (and remove this text!) to get reduced vertical space in + compound paragraphs. +div.compound .compound-first, div.compound .compound-middle { + margin-bottom: 0.5em } + +div.compound .compound-last, div.compound .compound-middle { + margin-top: 0.5em } +*/ + +div.dedication { + margin: 2em 5em ; + text-align: center ; + font-style: italic } + +div.dedication p.topic-title { + font-weight: bold ; + font-style: normal } + +div.figure { + margin-left: 2em ; + margin-right: 2em } + +div.footer, div.header { + clear: both; + font-size: smaller } + +div.line-block { + display: block ; + margin-top: 1em ; + margin-bottom: 1em } + +div.line-block div.line-block { + margin-top: 0 ; + margin-bottom: 0 ; + margin-left: 1.5em } + +div.sidebar { + margin: 0 0 0.5em 1em ; + border: medium outset ; + padding: 1em ; + background-color: #ffffee ; + width: 40% ; + float: right ; + clear: right } + +div.sidebar p.rubric { + font-family: sans-serif ; + font-size: medium } + +div.system-messages { + margin: 5em } + +div.system-messages h1 { + color: red } + +div.system-message { + border: medium outset ; + padding: 1em } + +div.system-message p.system-message-title { + color: red ; + font-weight: bold } + +div.topic { + margin: 2em } + +h1.section-subtitle, h2.section-subtitle, h3.section-subtitle, +h4.section-subtitle, h5.section-subtitle, h6.section-subtitle { + margin-top: 0.4em } + +h1.title { + text-align: center } + +h2.subtitle { + text-align: center } + +hr.docutils { + width: 75% } + +img.align-left, .figure.align-left, object.align-left, table.align-left { + clear: left ; + float: left ; + margin-right: 1em } + +img.align-right, .figure.align-right, object.align-right, table.align-right { + clear: right ; + float: right ; + margin-left: 1em } + +img.align-center, .figure.align-center, object.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} + +table.align-center { + margin-left: auto; + margin-right: auto; +} + +.align-left { + text-align: left } + +.align-center { + clear: both ; + text-align: center } + +.align-right { + text-align: right } + +/* reset inner alignment in figures */ +div.align-right { + text-align: inherit } + +/* div.align-center * { */ +/* text-align: left } */ + +.align-top { + vertical-align: top } + +.align-middle { + vertical-align: middle } + +.align-bottom { + vertical-align: bottom } + +ol.simple, ul.simple { + margin-bottom: 1em } + +ol.arabic { + list-style: decimal } + +ol.loweralpha { + list-style: lower-alpha } + +ol.upperalpha { + list-style: upper-alpha } + +ol.lowerroman { + list-style: lower-roman } + +ol.upperroman { + list-style: upper-roman } + +p.attribution { + text-align: right ; + margin-left: 50% } + +p.caption { + font-style: italic } + +p.credits { + font-style: italic ; + font-size: smaller } + +p.label { + white-space: nowrap } + +p.rubric { + font-weight: bold ; + font-size: larger ; + color: maroon ; + text-align: center } + +p.sidebar-title { + font-family: sans-serif ; + font-weight: bold ; + font-size: larger } + +p.sidebar-subtitle { + font-family: sans-serif ; + font-weight: bold } + +p.topic-title { + font-weight: bold } + +pre.address { + margin-bottom: 0 ; + margin-top: 0 ; + font: inherit } + +pre.literal-block, pre.doctest-block, pre.math, pre.code { + margin-left: 2em ; + margin-right: 2em } + +pre.code .ln { color: gray; } /* line numbers */ +pre.code, code { background-color: #eeeeee } +pre.code .comment, code .comment { color: #5C6576 } +pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } +pre.code .literal.string, code .literal.string { color: #0C5404 } +pre.code .name.builtin, code .name.builtin { color: #352B84 } +pre.code .deleted, code .deleted { background-color: #DEB0A1} +pre.code .inserted, code .inserted { background-color: #A3D289} + +span.classifier { + font-family: sans-serif ; + font-style: oblique } + +span.classifier-delimiter { + font-family: sans-serif ; + font-weight: bold } + +span.interpreted { + font-family: sans-serif } + +span.option { + white-space: nowrap } + +span.pre { + white-space: pre } + +span.problematic, pre.problematic { + color: red } + +span.section-subtitle { + /* font-size relative to parent (h1..h6 element) */ + font-size: 80% } + +table.citation { + border-left: solid 1px gray; + margin-left: 1px } + +table.docinfo { + margin: 2em 4em } + +table.docutils { + margin-top: 0.5em ; + margin-bottom: 0.5em } + +table.footnote { + border-left: solid 1px black; + margin-left: 1px } + +table.docutils td, table.docutils th, +table.docinfo td, table.docinfo th { + padding-left: 0.5em ; + padding-right: 0.5em ; + vertical-align: top } + +table.docutils th.field-name, table.docinfo th.docinfo-name { + font-weight: bold ; + text-align: left ; + white-space: nowrap ; + padding-left: 0 } + +/* "booktabs" style (no vertical lines) */ +table.docutils.booktabs { + border: 0px; + border-top: 2px solid; + border-bottom: 2px solid; + border-collapse: collapse; +} +table.docutils.booktabs * { + border: 0px; +} +table.docutils.booktabs th { + border-bottom: thin solid; + text-align: left; +} + +h1 tt.docutils, h2 tt.docutils, h3 tt.docutils, +h4 tt.docutils, h5 tt.docutils, h6 tt.docutils { + font-size: 100% } + +ul.auto-toc { + list-style-type: none } + +</style> +</head> +<body> +<div class="document" id="compute-product-sales-price-from-a-pricelist"> +<h1 class="title">Compute product sales price from a pricelist</h1> + +<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +!! This file is generated by oca-gen-addon-readme !! +!! changes will be overwritten. !! +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +!! source digest: sha256:1033e6116f2ea3d86c1b0a87d872a082b85b265a83f24fd660f9425936d37b6b +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> +<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/product-attribute/tree/16.0/product_list_price_from_pricelist"><img alt="OCA/product-attribute" src="https://img.shields.io/badge/github-OCA%2Fproduct--attribute-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/product-attribute-16-0/product-attribute-16-0-product_list_price_from_pricelist"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/product-attribute&target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p> +<p>This module enables the automatic computation of a product’s sale price based on the configuration of a pricelist.</p> +<p><strong>Table of contents</strong></p> +<div class="contents local topic" id="contents"> +<ul class="simple"> +<li><a class="reference internal" href="#configuration" id="toc-entry-1">Configuration</a></li> +<li><a class="reference internal" href="#usage" id="toc-entry-2">Usage</a></li> +<li><a class="reference internal" href="#known-issues-roadmap" id="toc-entry-3">Known issues / Roadmap</a></li> +<li><a class="reference internal" href="#bug-tracker" id="toc-entry-4">Bug Tracker</a></li> +<li><a class="reference internal" href="#credits" id="toc-entry-5">Credits</a><ul> +<li><a class="reference internal" href="#authors" id="toc-entry-6">Authors</a></li> +<li><a class="reference internal" href="#contributors" id="toc-entry-7">Contributors</a></li> +<li><a class="reference internal" href="#maintainers" id="toc-entry-8">Maintainers</a></li> +</ul> +</li> +</ul> +</div> +<div class="section" id="configuration"> +<h1><a class="toc-backref" href="#toc-entry-1">Configuration</a></h1> +<ul class="simple"> +<li>Go to <cite>Sales</cite> -> <cite>Products</cite> -> <cite>Pricelists</cite>.</li> +<li>Create a new pricelist and add at least one rule.</li> +<li>Specify the product template or category for the rule.</li> +<li>Set the <cite>computation mode</cite> and save</li> +</ul> +<p><strong>Note</strong>: Ensure the minimum quantity is not great than 1 for the rule to apply effectively.</p> +<ul class="simple"> +<li>Go to <cite>Sales</cite> -> <cite>Configuration</cite> -> <cite>Settings</cite>.</li> +<li>In the <cite>Pricing</cite> section, select the <cite>Pricelist to compute sale price</cite> created in the previous step.</li> +<li>Optionally and only with a multi-company environment enabled, set the <cite>Main company for compute sale price</cite> to restrict the computation to a specific company.</li> +<li>Save the configuration</li> +</ul> +<p>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 <cite>Settings</cite> -> <cite>Technical</cite> -> <cite>Automation</cite> -> <cite>Scheduled Actions</cite> and search for <cite>Product sale price: Update price from pricelist</cite>.</p> +</div> +<div class="section" id="usage"> +<h1><a class="toc-backref" href="#toc-entry-2">Usage</a></h1> +<p><strong>To update product prices according to the pricelist rules</strong></p> +<ul class="simple"> +<li>Stay in the settings configuration with the selected Pricelist.</li> +<li>Click the <strong>Update Product Prices</strong> button to apply the rules and update the sale prices of all products.</li> +</ul> +</div> +<div class="section" id="known-issues-roadmap"> +<h1><a class="toc-backref" href="#toc-entry-3">Known issues / Roadmap</a></h1> +<p>The <cite>list_price</cite> field is not <cite>company-dependent</cite>, 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 <cite>Main company for computing sale price</cite>.</p> +</div> +<div class="section" id="bug-tracker"> +<h1><a class="toc-backref" href="#toc-entry-4">Bug Tracker</a></h1> +<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/product-attribute/issues">GitHub Issues</a>. +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 +<a class="reference external" href="https://github.com/OCA/product-attribute/issues/new?body=module:%20product_list_price_from_pricelist%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p> +<p>Do not contact contributors directly about support or help with technical issues.</p> +</div> +<div class="section" id="credits"> +<h1><a class="toc-backref" href="#toc-entry-5">Credits</a></h1> +<div class="section" id="authors"> +<h2><a class="toc-backref" href="#toc-entry-6">Authors</a></h2> +<ul class="simple"> +<li>Tecnativa</li> +</ul> +</div> +<div class="section" id="contributors"> +<h2><a class="toc-backref" href="#toc-entry-7">Contributors</a></h2> +<ul class="simple"> +<li><a class="reference external" href="https://www.tecnativa.com">Tecnativa</a><ul> +<li>Pedro M. Baeza</li> +<li>Carlos López</li> +</ul> +</li> +</ul> +</div> +<div class="section" id="maintainers"> +<h2><a class="toc-backref" href="#toc-entry-8">Maintainers</a></h2> +<p>This module is maintained by the OCA.</p> +<a class="reference external image-reference" href="https://odoo-community.org"> +<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /> +</a> +<p>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.</p> +<p>Current <a class="reference external" href="https://odoo-community.org/page/maintainer-role">maintainer</a>:</p> +<p><a class="reference external image-reference" href="https://github.com/carlos-lopez-tecnativa"><img alt="carlos-lopez-tecnativa" src="https://github.com/carlos-lopez-tecnativa.png?size=40px" /></a></p> +<p>This module is part of the <a class="reference external" href="https://github.com/OCA/product-attribute/tree/16.0/product_list_price_from_pricelist">OCA/product-attribute</a> project on GitHub.</p> +<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p> +</div> +</div> +</div> +</body> +</html> 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 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + + <record id="view_res_config_settings_form" model="ir.ui.view"> + <field name="name">view.res.config.settings.form</field> + <field name="model">res.config.settings</field> + <field name="inherit_id" ref="sale.res_config_settings_view_form" /> + <field name="arch" type="xml"> + <xpath expr="//div[@id='pricelist_configuration']" position="after"> + <div + class="col-12 col-lg-6 o_setting_box" + id="pricelist_configuration" + groups="product.group_product_pricelist" + > + <div class="o_setting_left_pane" /> + <div class="o_setting_right_pane"> + <label for="base_pricelist_compute_price_id" /> + <div class="text-muted"> + Set the base pricelist to compute the sales price for all the products. + <br /> + <div class="alert alert-warning mt8" role="alert"> + 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 + </div> + </div> + <div class="content-group"> + <div class="mt16"> + <field + name="base_pricelist_compute_price_id" + options="{'no_create': True}" + class="o_light_label" + /> + </div> + <div class="mt8"> + <button + name="action_update_product_price_from_pricelist" + icon="fa-arrow-right" + type="object" + string="Update product prices" + groups="product.group_product_pricelist" + attrs="{'invisible': [('base_pricelist_compute_price_id', '=', False)]}" + confirm="Are you sure you want to update the prices for all products?. This operations cannot be undone." + class="btn-link" + /> + </div> + </div> + <div class="content-group" groups="base.group_multi_company"> + <div class="mt16"> + <label for="main_company_compute_price_id" /> + <div class="text-muted"> + If set, prices will be computed only if the company in the product matches the company specified here or is empty. + Otherwise, prices will be computed based on the current company. + </div> + <field + name="main_company_compute_price_id" + options="{'no_create': True}" + class="o_light_label" + /> + </div> + </div> + </div> + </div> + </xpath> + </field> + </record> + +</odoo> 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, +)