diff --git a/website_sale_product_minimal_price/README.rst b/website_sale_product_minimal_price/README.rst new file mode 100644 index 0000000000..2eddd64e74 --- /dev/null +++ b/website_sale_product_minimal_price/README.rst @@ -0,0 +1,103 @@ +================================== +Website Sale Product Minimal Price +================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:92596688b8e751198e91eae560742c53b54b75366b9905677fec18f32a2ca0a3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png + :target: https://odoo-community.org/page/development-status + :alt: Production/Stable +.. |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%2Fe--commerce-lightgray.png?logo=github + :target: https://github.com/OCA/e-commerce/tree/18.0/website_sale_product_minimal_price + :alt: OCA/e-commerce +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/e-commerce-18-0/e-commerce-18-0-website_sale_product_minimal_price + :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/e-commerce&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the functionality of website sale module to allow to +display the minimal price in '/shop' view when product has distinct +variants price and set order by minimal price in product's view. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Go to backend and set a product with variants and extra price by + attribute value or define a distinct prices in public price list for + this variant. +2. Go to Website Shop. +3. You will see that in main products view appears the text "From " with + minimal price if the product has a distinct prices by attribute. +4. Click on product, the price displayed is the minimal variant 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 `__: + + - Sergio Teruel + - Carlos Roca + - Pedro M. Baeza + - Pilar Vargas + - Carlos Lopez + +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-sergio-teruel| image:: https://github.com/sergio-teruel.png?size=40px + :target: https://github.com/sergio-teruel + :alt: sergio-teruel + +Current `maintainer `__: + +|maintainer-sergio-teruel| + +This module is part of the `OCA/e-commerce `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_sale_product_minimal_price/__init__.py b/website_sale_product_minimal_price/__init__.py new file mode 100644 index 0000000000..31660d6a96 --- /dev/null +++ b/website_sale_product_minimal_price/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/website_sale_product_minimal_price/__manifest__.py b/website_sale_product_minimal_price/__manifest__.py new file mode 100644 index 0000000000..211969b9e1 --- /dev/null +++ b/website_sale_product_minimal_price/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2019 Tecnativa - Sergio Teruel +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Website Sale Product Minimal Price", + "summary": "Display minimal price for products that has variants", + "version": "18.0.1.0.0", + "development_status": "Production/Stable", + "maintainers": ["sergio-teruel"], + "category": "Website", + "website": "https://github.com/OCA/e-commerce", + "author": "Tecnativa, Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": ["website_sale"], + "data": ["views/templates.xml"], + "assets": { + "web.assets_frontend": [ + "/website_sale_product_minimal_price/static/src/js/*.esm.js", + "/website_sale_product_minimal_price/static/src/xml/*.xml", + ], + "web.assets_tests": [ + "/website_sale_product_minimal_price/static/src/tests/**/*.esm.js" + ], + }, +} diff --git a/website_sale_product_minimal_price/i18n/ca.po b/website_sale_product_minimal_price/i18n/ca.po new file mode 100644 index 0000000000..ec7a0aabad --- /dev/null +++ b/website_sale_product_minimal_price/i18n/ca.po @@ -0,0 +1,40 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_product_minimal_price +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-05-17 14:53+0000\n" +"PO-Revision-Date: 2021-05-17 16:54+0200\n" +"Last-Translator: Carlos \n" +"Language-Team: none\n" +"Language: ca\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 2.0.6\n" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_minimal_price.xml:0 +#, python-format +msgid "From" +msgstr "De" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_price_scale.xml:0 +#, python-format +msgid "Prices per quantity (" +msgstr "Preus per quantitat (" + +#. module: website_sale_product_minimal_price +#: model:ir.model,name:website_sale_product_minimal_price.model_product_template +msgid "Product" +msgstr "" + +#~ msgid "Product Template" +#~ msgstr "Plantilla de producte" diff --git a/website_sale_product_minimal_price/i18n/es.po b/website_sale_product_minimal_price/i18n/es.po new file mode 100644 index 0000000000..3c5c320866 --- /dev/null +++ b/website_sale_product_minimal_price/i18n/es.po @@ -0,0 +1,40 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_product_minimal_price +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-05-17 14:53+0000\n" +"PO-Revision-Date: 2021-05-17 16:54+0200\n" +"Last-Translator: Carlos \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 2.0.6\n" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_minimal_price.xml:0 +#, python-format +msgid "From" +msgstr "Desde" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_price_scale.xml:0 +#, python-format +msgid "Prices per quantity (" +msgstr "Precios por cantidad (" + +#. module: website_sale_product_minimal_price +#: model:ir.model,name:website_sale_product_minimal_price.model_product_template +msgid "Product" +msgstr "" + +#~ msgid "Product Template" +#~ msgstr "Plantilla de producto" diff --git a/website_sale_product_minimal_price/i18n/fr.po b/website_sale_product_minimal_price/i18n/fr.po new file mode 100644 index 0000000000..816fbaf98a --- /dev/null +++ b/website_sale_product_minimal_price/i18n/fr.po @@ -0,0 +1,39 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_product_minimal_price +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-09-03 13:35+0000\n" +"Last-Translator: benj-filament \n" +"Language-Team: none\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_minimal_price.xml:0 +#, python-format +msgid "From" +msgstr "À partir de" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_price_scale.xml:0 +#, python-format +msgid "Prices per quantity (" +msgstr "Prix par quantité (" + +#. module: website_sale_product_minimal_price +#: model:ir.model,name:website_sale_product_minimal_price.model_product_template +msgid "Product" +msgstr "" + +#~ msgid "Product Template" +#~ msgstr "Modèle d'article" diff --git a/website_sale_product_minimal_price/i18n/it.po b/website_sale_product_minimal_price/i18n/it.po new file mode 100644 index 0000000000..7fef835fef --- /dev/null +++ b/website_sale_product_minimal_price/i18n/it.po @@ -0,0 +1,36 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_product_minimal_price +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-05-08 16:34+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_minimal_price.xml:0 +#, python-format +msgid "From" +msgstr "Dal" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_price_scale.xml:0 +#, python-format +msgid "Prices per quantity (" +msgstr "Prezzi per quantità (" + +#. module: website_sale_product_minimal_price +#: model:ir.model,name:website_sale_product_minimal_price.model_product_template +msgid "Product" +msgstr "Prodotto" diff --git a/website_sale_product_minimal_price/i18n/nl.po b/website_sale_product_minimal_price/i18n/nl.po new file mode 100644 index 0000000000..5995633326 --- /dev/null +++ b/website_sale_product_minimal_price/i18n/nl.po @@ -0,0 +1,40 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_product_minimal_price +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-05-17 14:53+0000\n" +"PO-Revision-Date: 2021-05-17 16:55+0200\n" +"Last-Translator: Carlos \n" +"Language-Team: none\n" +"Language: nl\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 2.0.6\n" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_minimal_price.xml:0 +#, python-format +msgid "From" +msgstr "Van" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_price_scale.xml:0 +#, python-format +msgid "Prices per quantity (" +msgstr "" + +#. module: website_sale_product_minimal_price +#: model:ir.model,name:website_sale_product_minimal_price.model_product_template +msgid "Product" +msgstr "" + +#~ msgid "Product Template" +#~ msgstr "Product Sjabloon" diff --git a/website_sale_product_minimal_price/i18n/pt.po b/website_sale_product_minimal_price/i18n/pt.po new file mode 100644 index 0000000000..d013857b84 --- /dev/null +++ b/website_sale_product_minimal_price/i18n/pt.po @@ -0,0 +1,48 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_product_minimal_price +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2022-08-22 14:07+0000\n" +"Last-Translator: Pedro Castro Silva \n" +"Language-Team: none\n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.3.2\n" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_minimal_price.xml:0 +#, python-format +msgid "From" +msgstr "A partir de" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_price_scale.xml:0 +#, python-format +msgid "Prices per quantity (" +msgstr "Preços por quantidade (" + +#. module: website_sale_product_minimal_price +#: model:ir.model,name:website_sale_product_minimal_price.model_product_template +msgid "Product" +msgstr "" + +#~ msgid "Product Template" +#~ msgstr "Modelo de Produto" + +#~ msgid "Display Name" +#~ msgstr "Nome a Exibir" + +#~ msgid "ID" +#~ msgstr "ID" + +#~ msgid "Last Modified on" +#~ msgstr "Modific. pela última vez em" diff --git a/website_sale_product_minimal_price/i18n/website_sale_product_minimal_price.pot b/website_sale_product_minimal_price/i18n/website_sale_product_minimal_price.pot new file mode 100644 index 0000000000..af4c2060fc --- /dev/null +++ b/website_sale_product_minimal_price/i18n/website_sale_product_minimal_price.pot @@ -0,0 +1,31 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_product_minimal_price +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: website_sale_product_minimal_price +#: model_terms:ir.ui.view,arch_db:website_sale_product_minimal_price.products_item +msgid "From" +msgstr "" + +#. module: website_sale_product_minimal_price +#. odoo-javascript +#: code:addons/website_sale_product_minimal_price/static/src/xml/website_sale_product_price_scale.xml:0 +#, python-format +msgid "Prices per quantity (" +msgstr "" + +#. module: website_sale_product_minimal_price +#: model:ir.model,name:website_sale_product_minimal_price.model_product_template +msgid "Product" +msgstr "" diff --git a/website_sale_product_minimal_price/models/__init__.py b/website_sale_product_minimal_price/models/__init__.py new file mode 100644 index 0000000000..e8fa8f6bf1 --- /dev/null +++ b/website_sale_product_minimal_price/models/__init__.py @@ -0,0 +1 @@ +from . import product_template diff --git a/website_sale_product_minimal_price/models/product_template.py b/website_sale_product_minimal_price/models/product_template.py new file mode 100644 index 0000000000..e9b1aedf1e --- /dev/null +++ b/website_sale_product_minimal_price/models/product_template.py @@ -0,0 +1,200 @@ +# Copyright 2019 Tecnativa - Sergio Teruel +# Copyright 2020 Tecnativa - Pedro M. Baeza +# Copyright 2021 Tecnativa - Carlos Roca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models +from odoo.osv import expression + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + def _get_product_subpricelists(self, pricelist): + base_domain = pricelist._get_applicable_rules_domain( + self, fields.Datetime.now() + ) + domain = expression.AND( + [ + base_domain, + [("compute_price", "=", "formula"), ("base", "=", "pricelist")], + ] + ) + pricelist_data = self.env["product.pricelist.item"]._read_group( + domain, + groupby=["base_pricelist_id"], + aggregates=["base_pricelist_id:array_agg"], + ) + pricelist_ids = [item for line in pricelist_data for item in line[1]] + return self.env["product.pricelist"].browse(pricelist_ids) + + def _get_variants_from_pricelist(self, pricelist): + return pricelist.mapped("item_ids").filtered( + lambda i: i.product_id in self.product_variant_ids + ) + + def _get_pricelist_variant_items(self, pricelist): + res = self._get_variants_from_pricelist(pricelist) + next_pricelists = self._get_product_subpricelists(pricelist) + res |= self._get_variants_from_pricelist(next_pricelists) + visited_pricelists = pricelist + while next_pricelists: + pricelist = next_pricelists[0] + if pricelist not in visited_pricelists: + res |= self._get_variants_from_pricelist(pricelist) + next_pricelists |= self._get_product_subpricelists(pricelist) + next_pricelists -= pricelist + visited_pricelists |= pricelist + else: + next_pricelists -= pricelist + return res + + def _get_cheapest_info(self, pricelist): + """Helper method for getting the variant with lowest price.""" + # TODO: Cache this method for getting better performance + self.ensure_one() + min_price = 99999999 + product_find = self.env["product.product"] + add_qty = 0 + has_distinct_price = False + # Variants with extra price + variants_extra_price = self.product_variant_ids.filtered("price_extra") + variants_without_extra_price = self.product_variant_ids - variants_extra_price + # Avoid compute prices when pricelist has not item variants defined + variant_items = self._get_pricelist_variant_items(pricelist) + if variant_items: + # Take into account only the variants defined in pricelist and one + # variant not defined to compute prices defined at template or + # category level. Maybe there is any definition on template that + # has cheaper price. + variants = variant_items.mapped("product_id") + products = variants + (self.product_variant_ids - variants)[:1] + else: + products = variants_without_extra_price[:1] + products |= variants_extra_price + for product in products: + for qty in [1, 99999999]: + product_price = product.with_context( + quantity=qty, pricelist=pricelist.id + )._get_contextual_price() + if product_price != min_price and min_price != 99999999: + # Mark if there are different prices iterating over + # variants and comparing qty 1 and maximum qty + has_distinct_price = True + if product_price < min_price: + min_price = product_price + add_qty = qty + product_find = product + return product_find, add_qty, has_distinct_price + + def _get_first_possible_combination( + self, parent_combination=None, necessary_values=None + ): + """Get the cheaper product combination for the website view.""" + res = super()._get_first_possible_combination( + parent_combination=parent_combination, necessary_values=necessary_values + ) + context = self.env.context + if context.get("website_id") and self.product_variant_count > 1: + # It only makes sense to change the default one when there are + # more than one variants and we know the pricelist + current_website = self.env["website"].get_current_website() + pricelist = current_website.pricelist_id + product = self._get_cheapest_info(pricelist)[0] + # Rebuild the combination in the expected order + res = self.env["product.template.attribute.value"] + for line in product.valid_product_template_attribute_line_ids: + value = product.product_template_attribute_value_ids.filtered( + lambda x, line=line: x in line.product_template_value_ids + ) + if not value: + value = line.product_template_value_ids[:1] + res += value + return res + + def _get_combination_info( + self, + combination=False, + product_id=False, + add_qty=1, + parent_combination=False, + only_template=False, + ): + combination_info = super()._get_combination_info( + combination=combination, + product_id=product_id, + add_qty=add_qty, + parent_combination=parent_combination, + only_template=only_template, + ) + if only_template and not product_id: + return combination_info + combination = combination or self.env["product.template.attribute.value"] + if only_template: + product = self.env["product.product"] + elif product_id: + product = self.env["product.product"].browse(product_id) + if combination - product.product_template_attribute_value_ids: + # If the combination is not fully represented in the given product + # make sure to fetch the right product for the given combination + product = self._get_variant_for_combination(combination) + else: + product = self._get_variant_for_combination(combination) + if not product: + # If no product is found, return the combination info without prices + # the combination is not valid for the product or the product is archived + return combination_info + # Getting all min_quantity of the current product to compute the possible + # price scale. + qty_list = self.env["product.pricelist.item"].search( + [ + "|", + ("product_id", "=", product.id), + "|", + ("product_tmpl_id", "=", product.product_tmpl_id.id), + ( + "categ_id", + "in", + list(map(int, product.categ_id.parent_path.split("/")[0:-1])), + ), + ("min_quantity", ">", 0), + ] + ) + qty_list = sorted(set(qty_list.mapped("min_quantity"))) + price_scale = [] + last_price = product.with_context(quantity=0)._get_contextual_price() + for min_qty in qty_list: + new_price = product.with_context(quantity=min_qty)._get_contextual_price() + if new_price != last_price: + price_scale.append( + { + "min_qty": min_qty, + "price": new_price, + "currency_id": product.currency_id.id, + } + ) + last_price = new_price + combination_info.update( + uom_name=product.uom_id.name, + minimal_price_scale=price_scale, + ) + return combination_info + + def _get_sales_prices(self, website): + prices = super()._get_sales_prices(website) + pricelist = website.pricelist_id + for template in self.filtered("is_published"): + price_info = prices[template.id] + product, add_qty, has_distinct_price = template._get_cheapest_info( + pricelist + ) + product_price_info = template._get_additionnal_combination_info( + product, + quantity=add_qty, + date=fields.Date.context_today(self), + website=website, + ) + price_info.update( + distinct_prices=has_distinct_price, + price=product_price_info["list_price"], + ) + return prices diff --git a/website_sale_product_minimal_price/pyproject.toml b/website_sale_product_minimal_price/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/website_sale_product_minimal_price/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_sale_product_minimal_price/readme/CONTRIBUTORS.md b/website_sale_product_minimal_price/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..4103ee09d6 --- /dev/null +++ b/website_sale_product_minimal_price/readme/CONTRIBUTORS.md @@ -0,0 +1,7 @@ +- [Tecnativa](https://www.tecnativa.com): + + - Sergio Teruel + - Carlos Roca + - Pedro M. Baeza + - Pilar Vargas + - Carlos Lopez diff --git a/website_sale_product_minimal_price/readme/DESCRIPTION.md b/website_sale_product_minimal_price/readme/DESCRIPTION.md new file mode 100644 index 0000000000..48325f9367 --- /dev/null +++ b/website_sale_product_minimal_price/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module extends the functionality of website sale module to allow to +display the minimal price in '/shop' view when product has distinct +variants price and set order by minimal price in product's view. diff --git a/website_sale_product_minimal_price/readme/USAGE.md b/website_sale_product_minimal_price/readme/USAGE.md new file mode 100644 index 0000000000..18776d2ead --- /dev/null +++ b/website_sale_product_minimal_price/readme/USAGE.md @@ -0,0 +1,8 @@ +1. Go to backend and set a product with variants and extra price by + attribute value or define a distinct prices in public price list for + this variant. +2. Go to Website Shop. +3. You will see that in main products view appears the text "From " + with minimal price if the product has a distinct prices by + attribute. +4. Click on product, the price displayed is the minimal variant price. diff --git a/website_sale_product_minimal_price/static/description/icon.png b/website_sale_product_minimal_price/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/website_sale_product_minimal_price/static/description/icon.png differ diff --git a/website_sale_product_minimal_price/static/description/index.html b/website_sale_product_minimal_price/static/description/index.html new file mode 100644 index 0000000000..822c532170 --- /dev/null +++ b/website_sale_product_minimal_price/static/description/index.html @@ -0,0 +1,447 @@ + + + + + +Website Sale Product Minimal Price + + + +
+

Website Sale Product Minimal Price

+ + +

Production/Stable License: AGPL-3 OCA/e-commerce Translate me on Weblate Try me on Runboat

+

This module extends the functionality of website sale module to allow to +display the minimal price in ‘/shop’ view when product has distinct +variants price and set order by minimal price in product’s view.

+

Table of contents

+ +
+

Usage

+
    +
  1. Go to backend and set a product with variants and extra price by +attribute value or define a distinct prices in public price list for +this variant.
  2. +
  3. Go to Website Shop.
  4. +
  5. You will see that in main products view appears the text “From “ with +minimal price if the product has a distinct prices by attribute.
  6. +
  7. Click on product, the price displayed is the minimal variant price.
  8. +
+
+
+

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:
      +
    • Sergio Teruel
    • +
    • Carlos Roca
    • +
    • Pedro M. Baeza
    • +
    • Pilar Vargas
    • +
    • Carlos Lopez
    • +
    +
  • +
+
+
+

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:

+

sergio-teruel

+

This module is part of the OCA/e-commerce project on GitHub.

+

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

+
+
+
+ + diff --git a/website_sale_product_minimal_price/static/src/js/website_sale_product_price_scale.esm.js b/website_sale_product_minimal_price/static/src/js/website_sale_product_price_scale.esm.js new file mode 100644 index 0000000000..eeb2e55136 --- /dev/null +++ b/website_sale_product_minimal_price/static/src/js/website_sale_product_price_scale.esm.js @@ -0,0 +1,61 @@ +/* Copyright 2021 Carlos Roca + * Copyright 2025 Carlos Lopez - Tecnativa + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {WebsiteSale} from "@website_sale/js/website_sale"; +import {formatCurrency} from "@web/core/currency"; +import {renderToString} from "@web/core/utils/render"; + +WebsiteSale.include({ + /** + * @override + * Render the price scale of the product + * based on the selected combination and current pricelist . + */ + _onChangeCombination: function (ev, $parent, combination) { + const res = this._super(...arguments); + if (!this.isWebsite || combination.product_id === false) { + return res; + } + const unit_prices = combination.minimal_price_scale; + const uom_name = combination.uom_name; + $(".temporal").remove(); + if (unit_prices.length <= 0) { + return res; + } + const $form = $('form[action*="/shop/cart/update"]'); + $form.append('
'); + $form.append( + renderToString("website_sale_product_minimal_price.title", {uom: uom_name}) + ); + // We define a limit of displayed columns as 4 + const limit_col = 4; + let $div; // eslint-disable-line init-declarations + for (const i in unit_prices) { + if (unit_prices[i].price === 0) { + continue; + } + if (i % limit_col === 0) { + const id = i / limit_col; + $form.append('
'); + $div = $("#row_" + id); + } + let monetary_u = formatCurrency( + unit_prices[i].price, + unit_prices[i].currency_id + ); + monetary_u = monetary_u.replace(" ", " "); + $div.append( + renderToString("website_sale_product_minimal_price.pricelist", { + quantity: unit_prices[i].min_qty, + price: monetary_u, + }) + ); + } + $div = $('div[id*="row_"]'); + for (let i = 0; i < $div.length - 1; i++) { + $($div[i]).addClass("border-bottom"); + } + return res; + }, +}); diff --git a/website_sale_product_minimal_price/static/src/tests/tours/test_product_with_no_prices_tour.esm.js b/website_sale_product_minimal_price/static/src/tests/tours/test_product_with_no_prices_tour.esm.js new file mode 100644 index 0000000000..80eb741143 --- /dev/null +++ b/website_sale_product_minimal_price/static/src/tests/tours/test_product_with_no_prices_tour.esm.js @@ -0,0 +1,31 @@ +/* Copyright 2021 Carlos Roca + * Copyright 2025 Carlos Lopez - Tecnativa + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {registry} from "@web/core/registry"; +registry.category("web_tour.tours").add("test_product_with_no_prices", { + url: "/shop", + test: true, + steps: () => [ + { + trigger: + ".oe_product_cart:has(.product_price:has(span:contains('From'))) a:contains('My product test with no prices')", + content: "Product with label From", + }, + { + trigger: ".product_price:has(span:contains('10.00'))", + }, + { + trigger: "a[href='/shop']", + }, + { + trigger: + ".oe_product_cart:has(.product_price:has(span:contains('10.00'))) a:contains('My product test')", + }, + { + trigger: + ".oe_product_cart:has(.product_price:not(:has(span:contains('From'))):has(span:contains('20.00'))) a:contains('My product test no prices')", + content: "Product without label From", + }, + ], +}); diff --git a/website_sale_product_minimal_price/static/src/tests/tours/tour.esm.js b/website_sale_product_minimal_price/static/src/tests/tours/tour.esm.js new file mode 100644 index 0000000000..378436701e --- /dev/null +++ b/website_sale_product_minimal_price/static/src/tests/tours/tour.esm.js @@ -0,0 +1,27 @@ +/* Copyright 2019 Sergio Teruel + * Copyright 2025 Carlos Lopez - Tecnativa + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import {registry} from "@web/core/registry"; +registry.category("web_tour.tours").add("website_sale_product_minimal_price", { + url: "/shop", + test: true, + steps: () => [ + { + trigger: + ".o_wsale_product_information:has(span:contains('From')) a:contains('My product test with various prices')", + }, + { + trigger: "a[href='/shop']", + }, + { + trigger: "a:contains('My product test with various prices')", + }, + { + trigger: "a[href='/shop']", + }, + { + trigger: ".product_price:has(span:contains('125.00'))", + }, + ], +}); diff --git a/website_sale_product_minimal_price/static/src/xml/website_sale_product_price_scale.xml b/website_sale_product_minimal_price/static/src/xml/website_sale_product_price_scale.xml new file mode 100644 index 0000000000..3098c70c62 --- /dev/null +++ b/website_sale_product_minimal_price/static/src/xml/website_sale_product_price_scale.xml @@ -0,0 +1,18 @@ + + + +
+ Prices per quantity ( ) +
+
+ +
+
+ +
+
+ +
+
+
+
diff --git a/website_sale_product_minimal_price/tests/__init__.py b/website_sale_product_minimal_price/tests/__init__.py new file mode 100644 index 0000000000..cd56f818f4 --- /dev/null +++ b/website_sale_product_minimal_price/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_product_with_no_prices +from . import test_website_sale_product_minimal_price diff --git a/website_sale_product_minimal_price/tests/test_product_with_no_prices.py b/website_sale_product_minimal_price/tests/test_product_with_no_prices.py new file mode 100644 index 0000000000..83025e5d2b --- /dev/null +++ b/website_sale_product_minimal_price/tests/test_product_with_no_prices.py @@ -0,0 +1,107 @@ +# Copyright 2021 Tecnativa - Carlos Roca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.fields import Command +from odoo.tests import tagged +from odoo.tests.common import HttpCase + + +@tagged("post_install", "-at_install") +class TestProductWithNoPrices(HttpCase): + """With this test we are checking that the minimal price is set + when the product has not a price defined and the price of + variants depend on a subpricelist. + """ + + def setUp(self): + super().setUp() + ProductAttribute = self.env["product.attribute"] + ProductAttributeValue = self.env["product.attribute.value"] + self.category = self.env["product.category"].create({"name": "Test category"}) + self.product_attribute = ProductAttribute.create( + {"name": "Test", "create_variant": "always"} + ) + self.product_attribute_value_test_1 = ProductAttributeValue.create( + {"name": "Test v1", "attribute_id": self.product_attribute.id} + ) + self.product_attribute_value_test_2 = ProductAttributeValue.create( + {"name": "Test v2", "attribute_id": self.product_attribute.id} + ) + self.product_template_no_price = self.env["product.template"].create( + { + "name": "My product test no prices", + "is_published": True, + "type": "consu", + "website_sequence": 1, + "categ_id": self.category.id, + "list_price": 20, + } + ) + self.product_template = self.env["product.template"].create( + { + "name": "My product test with no prices", + "is_published": True, + "type": "consu", + "website_sequence": 1, + "categ_id": self.category.id, + "attribute_line_ids": [ + Command.create( + { + "attribute_id": self.product_attribute.id, + "value_ids": [ + Command.link(self.product_attribute_value_test_1.id), + Command.link(self.product_attribute_value_test_2.id), + ], + }, + ), + ], + } + ) + self.variant_1 = self.product_template.product_variant_ids[0] + self.variant_2 = self.product_template.product_variant_ids[1] + self.pricelist_aux = self.env["product.pricelist"].create( + { + "name": "Test pricelist Aux", + "selectable": True, + "item_ids": [ + Command.create( + { + "applied_on": "0_product_variant", + "product_id": self.variant_1.id, + "compute_price": "fixed", + "fixed_price": 10, + }, + ), + Command.create( + { + "applied_on": "0_product_variant", + "product_id": self.variant_2.id, + "compute_price": "fixed", + "fixed_price": 11, + }, + ), + ], + } + ) + self.pricelist_main = self.env["product.pricelist"].create( + { + "name": "Test pricelist Main", + "selectable": True, + "item_ids": [ + Command.create( + { + "applied_on": "2_product_category", + "categ_id": self.category.id, + "compute_price": "formula", + "base": "pricelist", + "base_pricelist_id": self.pricelist_aux.id, + }, + ) + ], + } + ) + user = self.env.ref("base.user_admin") + user.property_product_pricelist = self.pricelist_main + + def test_ui_website(self): + """Test frontend tour.""" + self.start_tour("/", "test_product_with_no_prices", login="admin") diff --git a/website_sale_product_minimal_price/tests/test_website_sale_product_minimal_price.py b/website_sale_product_minimal_price/tests/test_website_sale_product_minimal_price.py new file mode 100644 index 0000000000..8695fd6d85 --- /dev/null +++ b/website_sale_product_minimal_price/tests/test_website_sale_product_minimal_price.py @@ -0,0 +1,86 @@ +# Copyright 2019 Tecnativa - Sergio Teruel +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.fields import Command +from odoo.tests import tagged +from odoo.tests.common import HttpCase + + +@tagged("post_install", "-at_install") +class WebsiteSaleProductMinimalPriceHttpCase(HttpCase): + def setUp(self): + super().setUp() + # Create and select a pricelist + # to make tests pass no matter what l10n package is enabled + self.website = self.env["website"].get_current_website() + pricelist = self.env["product.pricelist"].create( + { + "name": "website_sale_product_minimal_price public", + "currency_id": self.env.company.currency_id.id, + "selectable": True, + "sequence": 1, + "website_id": self.website.id, + } + ) + self.env.ref("base.user_admin").property_product_pricelist = pricelist + # Models + ProductAttribute = self.env["product.attribute"] + ProductAttributeValue = self.env["product.attribute.value"] + ProductTmplAttributeValue = self.env["product.template.attribute.value"] + self.product_attribute = ProductAttribute.create( + {"name": "Test", "create_variant": "always"} + ) + self.product_attribute_value_test_1 = ProductAttributeValue.create( + {"name": "Test v1", "attribute_id": self.product_attribute.id} + ) + self.product_attribute_value_test_2 = ProductAttributeValue.create( + {"name": "Test v2", "attribute_id": self.product_attribute.id} + ) + self.product_template = self.env["product.template"].create( + { + "name": "My product test with various prices", + "is_published": True, + "type": "consu", + "list_price": 100.0, + "website_id": self.website.id, + "website_sequence": 1, + "attribute_line_ids": [ + Command.create( + { + "attribute_id": self.product_attribute.id, + "value_ids": [ + Command.link(self.product_attribute_value_test_1.id), + Command.link(self.product_attribute_value_test_2.id), + ], + }, + ), + ], + } + ) + product_tmpl_att_value = ProductTmplAttributeValue.search( + [ + ("product_tmpl_id", "=", self.product_template.id), + ("attribute_id", "=", self.product_attribute.id), + ( + "product_attribute_value_id", + "=", + self.product_attribute_value_test_1.id, + ), + ] + ) + product_tmpl_att_value.price_extra = 50.0 + product_tmpl_att_value = ProductTmplAttributeValue.search( + [ + ("product_tmpl_id", "=", self.product_template.id), + ("attribute_id", "=", self.product_attribute.id), + ( + "product_attribute_value_id", + "=", + self.product_attribute_value_test_2.id, + ), + ] + ) + product_tmpl_att_value.price_extra = 25.0 + + def test_ui_website(self): + """Test frontend tour.""" + self.start_tour("/shop", "website_sale_product_minimal_price", login="admin") diff --git a/website_sale_product_minimal_price/views/templates.xml b/website_sale_product_minimal_price/views/templates.xml new file mode 100644 index 0000000000..fb17e66d43 --- /dev/null +++ b/website_sale_product_minimal_price/views/templates.xml @@ -0,0 +1,60 @@ + + + + + +