From 8fc2e229f954158d74df5232d62e7b498e8d34b2 Mon Sep 17 00:00:00 2001 From: Kamen Zhekov Date: Fri, 27 Aug 2021 14:17:39 +0000 Subject: [PATCH] [IMP] website_sale: add price per unit on products Description of the issue/feature this PR addresses: Currently, we cannot display prices per unit (for example $/kg, etc.) on products shown in the shop. This is important for any eCommerce business that sells something other than units, or just for informational purposes. Desired behavior after PR is merged: Allows the user to add unit measurements for products. This behaviour is disabled by default, and can be enabled through the settings page by activating "eCommerce Base Unit Price" option. Enabling it displays a price per unit field in /shop's product page, comparison tool and cart. The price per unit of measurement is automatically computed and can be displayed both on the product, the cart and the comparison pages. Enabling it also modifies the product's configuration page to include what unit of measure to display in the price per unit field, as well as the number of units used to calculate the price per unit. task-2558128 Part-of: odoo/odoo#72242 Co-authored-by: Kamen Zhekov Co-authored-by: Jeremy Kersten --- addons/product/models/product_template.py | 14 +-- addons/website_sale/__manifest__.py | 1 + addons/website_sale/models/__init__.py | 1 + addons/website_sale/models/base_unit.py | 10 +++ addons/website_sale/models/product.py | 83 ++++++++++++++++- addons/website_sale/models/product_product.py | 88 +++++++++++++++++++ .../models/res_config_settings.py | 1 + .../website_sale/security/ir.model.access.csv | 2 + addons/website_sale/security/website_sale.xml | 5 ++ .../static/src/js/variant_mixin.js | 16 +++- addons/website_sale/views/base_unit_view.xml | 12 +++ addons/website_sale/views/product_views.xml | 47 ++++++++++ .../views/res_config_settings_views.xml | 12 +++ addons/website_sale/views/templates.xml | 19 +++- .../website_sale_comparison_template.xml | 3 + .../views/website_sale_wishlist_template.xml | 3 + 16 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 addons/website_sale/models/base_unit.py create mode 100644 addons/website_sale/models/product_product.py create mode 100644 addons/website_sale/views/base_unit_view.xml diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index c5c1e11184f..82b22ca8dbf 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -642,11 +642,7 @@ class ProductTemplate(models.Model): if combination in existing_variants: current_variants_to_activate += existing_variants[combination] else: - current_variants_to_create.append({ - 'product_tmpl_id': tmpl_id.id, - 'product_template_attribute_value_ids': [(6, 0, combination.ids)], - 'active': tmpl_id.active, - }) + current_variants_to_create.append(tmpl_id._prepare_variant_values(combination)) if len(current_variants_to_create) > 1000: raise UserError(_( 'The number of variants to generate is too high. ' @@ -682,6 +678,14 @@ class ProductTemplate(models.Model): self.invalidate_cache() return True + def _prepare_variant_values(self, combination): + self.ensure_one() + return { + 'product_tmpl_id': self.id, + 'product_template_attribute_value_ids': [(6, 0, combination.ids)], + 'active': self.active + } + def has_dynamic_attributes(self): """Return whether this `product.template` has at least one dynamic attribute. diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py index 712f2e21937..0a9fa9539b9 100644 --- a/addons/website_sale/__manifest__.py +++ b/addons/website_sale/__manifest__.py @@ -30,6 +30,7 @@ 'views/res_config_settings_views.xml', 'views/digest_views.xml', 'views/website_sale_visitor_views.xml', + 'views/base_unit_view.xml' ], 'demo': [ 'data/demo.xml', diff --git a/addons/website_sale/models/__init__.py b/addons/website_sale/models/__init__.py index 241b499eee2..c0ad1ccb936 100644 --- a/addons/website_sale/models/__init__.py +++ b/addons/website_sale/models/__init__.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import account_move +from . import base_unit from . import crm_team from . import ir_http from . import mail_compose_message diff --git a/addons/website_sale/models/base_unit.py b/addons/website_sale/models/base_unit.py new file mode 100644 index 00000000000..81e538b5635 --- /dev/null +++ b/addons/website_sale/models/base_unit.py @@ -0,0 +1,10 @@ +from odoo import fields, models + + +class BaseUnit(models.Model): + _name = "website.base.unit" + _description = "Unit of Measure for price per unit on eCommerce products." + _order = "name" + + name = fields.Char(help="Define a custom unit to display in the price per unit of measure field.", + required=True, translate=True) diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py index cb47cf64972..5fe419d6525 100644 --- a/addons/website_sale/models/product.py +++ b/addons/website_sale/models/product.py @@ -207,6 +207,52 @@ class ProductTemplate(models.Model): product_template_image_ids = fields.One2many('product.image', 'product_tmpl_id', string="Extra Product Media", copy=True) + base_unit_count = fields.Float('Base Unit Count', required=True, default=0, + compute='_compute_base_unit_count', inverse='_set_base_unit_count', store=True, + help="Display base unit price on your eCommerce pages. Set to 0 to hide it for this product.") + base_unit_id = fields.Many2one('website.base.unit', string='Custom Unit of Measure', + compute='_compute_base_unit_id', inverse='_set_base_unit_id', store=True, + help="Define a custom unit to display in the price per unit of measure field.") + base_unit_price = fields.Monetary("Price Per Unit", currency_field="currency_id", compute="_compute_base_unit_price") + base_unit_name = fields.Char(compute='_compute_base_unit_name', help='Displays the custom unit for the products if defined or the selected unit of measure otherwise.') + + @api.depends('product_variant_ids', 'product_variant_ids.base_unit_count') + def _compute_base_unit_count(self): + self.base_unit_count = 0 + for template in self.filtered(lambda template: len(template.product_variant_ids) == 1): + template.base_unit_count = template.product_variant_ids.base_unit_count + + def _set_base_unit_count(self): + for template in self: + if len(template.product_variant_ids) == 1: + template.product_variant_ids.base_unit_count = template.base_unit_count + + @api.depends('product_variant_ids', 'product_variant_ids.base_unit_count') + def _compute_base_unit_id(self): + self.base_unit_id = self.env['website.base.unit'] + for template in self.filtered(lambda template: len(template.product_variant_ids) == 1): + template.base_unit_id = template.product_variant_ids.base_unit_id + + def _set_base_unit_id(self): + for template in self: + if len(template.product_variant_ids) == 1: + template.product_variant_ids.base_unit_id = template.base_unit_id + + @api.depends('price', 'lst_price', 'base_unit_count') + def _compute_base_unit_price(self): + for template in self: + template.base_unit_price = template.base_unit_count and (template.price or template.lst_price) / template.base_unit_count + + @api.depends('uom_name', 'base_unit_id.name') + def _compute_base_unit_name(self): + for template in self: + template.base_unit_name = template.base_unit_id.name or template.uom_name + + def _prepare_variant_values(self, combination): + variant_dict = super()._prepare_variant_values(combination) + variant_dict['base_unit_count'] = self.base_unit_count + return variant_dict + def _get_website_accessory_product(self): domain = self.env['website'].sale_product_domain() return self.accessory_product_ids.filtered_domain(domain) @@ -294,9 +340,14 @@ class ProductTemplate(models.Model): parent_combination=parent_combination, only_template=only_template) if self.env.context.get('website_id'): + context = dict(self.env.context, ** { + 'quantity': self.env.context.get('quantity', add_qty), + 'pricelist': pricelist and pricelist.id + }) + + product = (self.env['product.product'].browse(combination_info['product_id']) or self).with_context(context) partner = self.env.user.partner_id company_id = current_website.company_id - product = self.env['product.product'].browse(combination_info['product_id']) or self tax_display = self.user_has_groups('account.group_show_line_subtotals_tax_excluded') and 'total_excluded' or 'total_included' fpos = self.env['account.fiscal.position'].sudo().get_fiscal_position(partner.id) @@ -317,6 +368,8 @@ class ProductTemplate(models.Model): has_discounted_price = pricelist.currency_id.compare_amounts(list_price, price) == 1 combination_info.update( + base_unit_name=product.base_unit_name, + base_unit_price=product.base_unit_price, price=price, list_price=list_price, has_discounted_price=has_discounted_price, @@ -451,6 +504,29 @@ class Product(models.Model): website_url = fields.Char('Website URL', compute='_compute_product_website_url', help='The full URL to access the document through the website.') + base_unit_count = fields.Float('Base Unit Count', required=True, default=1, help="Display base unit price on your eCommerce pages. Set to 0 to hide it for this product.") + base_unit_id = fields.Many2one('website.base.unit', string='Custom Unit of Measure', help="Define a custom unit to display in the price per unit of measure field.") + base_unit_price = fields.Monetary("Price Per Unit", currency_field="currency_id", compute="_compute_base_unit_price") + base_unit_name = fields.Char(compute='_compute_base_unit_name', help='Displays the custom unit for the products if defined or the selected unit of measure otherwise.') + + @api.depends('price', 'lst_price', 'base_unit_count') + def _compute_base_unit_price(self): + for product in self: + if not product.id: + product.base_unit_price = 0 + else: + product.base_unit_price = product.base_unit_count and (product.price or product.lst_price) / product.base_unit_count + + @api.depends('uom_name', 'base_unit_id') + def _compute_base_unit_name(self): + for product in self: + product.base_unit_name = product.base_unit_id.name or product.uom_name + + @api.constrains('base_unit_count') + def _check_base_unit_count(self): + if any(product.base_unit_count < 0 for product in self): + raise ValidationError(_('The value of Base Unit Count must be greater than 0. Use 0 to hide the price per unit on this product.')) + @api.depends_context('lang') @api.depends('product_tmpl_id.website_url', 'product_template_attribute_value_ids') def _compute_product_website_url(self): @@ -458,6 +534,11 @@ class Product(models.Model): attributes = ','.join(str(x) for x in product.product_template_attribute_value_ids.ids) product.website_url = "%s#attr=%s" % (product.product_tmpl_id.website_url, attributes) + def _prepare_variant_values(self, combination): + variant_dict = super()._prepare_variant_values(combination) + variant_dict['base_unit_count'] = self.base_unit_count + return variant_dict + def website_publish_button(self): self.ensure_one() return self.product_tmpl_id.website_publish_button() diff --git a/addons/website_sale/models/product_product.py b/addons/website_sale/models/product_product.py new file mode 100644 index 00000000000..d0fb1545563 --- /dev/null +++ b/addons/website_sale/models/product_product.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class Product(models.Model): + _inherit = "product.product" + + website_id = fields.Many2one(related='product_tmpl_id.website_id', readonly=False) + + product_variant_image_ids = fields.One2many('product.image', 'product_variant_id', string="Extra Variant Images") + + website_url = fields.Char('Website URL', compute='_compute_product_website_url', help='The full URL to access the document through the website.') + + base_unit_count = fields.Float('Base Unit Count', required=True, default=1, help="Display base unit price on your eCommerce pages. Set to 0 to hide it for this product.") + base_unit_id = fields.Many2one('website.base.unit', string='Custom Unit of Measure', help="Define a custom unit to display in the price per unit of measure field.") + base_unit_price = fields.Monetary("Price Per Unit", currency_field="currency_id", compute="_compute_base_unit_price") + base_unit_name = fields.Char(compute='_compute_base_unit_name', help='Displays the custom unit for the products if defined or the selected unit of measure otherwise.') + + @api.depends('price', 'lst_price', 'base_unit_count') + def _compute_base_unit_price(self): + for product in self: + if not product.id: + product.base_unit_price = 0 + else: + product.base_unit_price = product.base_unit_count and (product.price or product.lst_price) / product.base_unit_count + + @api.depends('uom_name', 'base_unit_id') + def _compute_base_unit_name(self): + for product in self: + product.base_unit_name = product.base_unit_id.name or product.uom_name + + @api.constrains('base_unit_count') + def _check_base_unit_count(self): + if any(product.base_unit_count < 0 for product in self): + raise ValidationError(_('The value of Base Unit Count must be greater than 0. Use 0 to hide the price per unit on this product.')) + + @api.depends_context('lang') + @api.depends('product_tmpl_id.website_url', 'product_template_attribute_value_ids') + def _compute_product_website_url(self): + for product in self: + attributes = ','.join(str(x) for x in product.product_template_attribute_value_ids.ids) + product.website_url = "%s#attr=%s" % (product.product_tmpl_id.website_url, attributes) + + def _prepare_variant_values(self, combination): + variant_dict = super()._prepare_variant_values(combination) + variant_dict['base_unit_count'] = self.base_unit_count + return variant_dict + + def website_publish_button(self): + self.ensure_one() + return self.product_tmpl_id.website_publish_button() + + def open_website_url(self): + self.ensure_one() + res = self.product_tmpl_id.open_website_url() + res['url'] = self.website_url + return res + + def _get_images(self): + """Return a list of records implementing `image.mixin` to + display on the carousel on the website for this variant. + + This returns a list and not a recordset because the records might be + from different models (template, variant and image). + + It contains in this order: the main image of the variant (if set), the + Variant Extra Images, and the Template Extra Images. + """ + self.ensure_one() + variant_images = list(self.product_variant_image_ids) + if self.image_variant_1920: + # if the main variant image is set, display it first + variant_images = [self] + variant_images + else: + # If the main variant image is empty, it will fallback to template + # image, in this case insert it after the other variant images, so + # that all variant images are first and all template images last. + variant_images = variant_images + [self] + # [1:] to remove the main image from the template, we only display + # the template extra images here + return variant_images + self.product_tmpl_id._get_images()[1:] + + def _is_sold_out(self): + combination_info = self.with_context(website_sale_stock_get_quantity=True).product_tmpl_id._get_combination_info(product_id=self.id) + return combination_info['product_type'] == 'product' and combination_info['free_qty'] <= 0 diff --git a/addons/website_sale/models/res_config_settings.py b/addons/website_sale/models/res_config_settings.py index 8827ec8ad73..816d74984d7 100644 --- a/addons/website_sale/models/res_config_settings.py +++ b/addons/website_sale/models/res_config_settings.py @@ -18,6 +18,7 @@ class ResConfigSettings(models.TransientModel): ], string="Shipping Management") group_delivery_invoice_address = fields.Boolean(string="Shipping Address", implied_group='sale.group_delivery_invoice_address', group='base.group_portal,base.group_user,base.group_public') + group_show_uom_price = fields.Boolean(default=False, string="Base Unit Price", implied_group="website_sale.group_show_uom_price", group='base.group_portal,base.group_user,base.group_public') module_website_sale_digital = fields.Boolean("Digital Content") module_website_sale_wishlist = fields.Boolean("Wishlists") diff --git a/addons/website_sale/security/ir.model.access.csv b/addons/website_sale/security/ir.model.access.csv index 2e5d2523d30..a0f3aed3f3c 100644 --- a/addons/website_sale/security/ir.model.access.csv +++ b/addons/website_sale/security/ir.model.access.csv @@ -23,3 +23,5 @@ access_product_image_sale,product.image sale,model_product_image,sales_team.grou access_website_sale_payment_acquirer_onboarding_wizard,access.website.sale.payment.acquirer.onboarding.wizard,model_website_sale_payment_acquirer_onboarding_wizard,base.group_system,1,1,1,0 access_ecom_extra_fields_public,access_ecom_extra_field public,model_website_sale_extra_field,,1,0,0,0 access_ecom_extra_fields_publisher,access_ecom_extra_field publisher,model_website_sale_extra_field,website.group_website_publisher,1,1,1,1 +access_website_base_unit_public,website.base.unit public,model_website_base_unit,,1,0,0,0 +access_website_base_unit_sale_manager,website.base.unit sale manager,model_website_base_unit,sales_team.group_sale_manager,1,1,1,1 diff --git a/addons/website_sale/security/website_sale.xml b/addons/website_sale/security/website_sale.xml index 4e14f89af8b..819db2a9bee 100644 --- a/addons/website_sale/security/website_sale.xml +++ b/addons/website_sale/security/website_sale.xml @@ -12,6 +12,11 @@ + + UOM Price Display for eCommerce + + + diff --git a/addons/website_sale/static/src/js/variant_mixin.js b/addons/website_sale/static/src/js/variant_mixin.js index a2afda68f5e..ab45708f2c2 100644 --- a/addons/website_sale/static/src/js/variant_mixin.js +++ b/addons/website_sale/static/src/js/variant_mixin.js @@ -11,12 +11,26 @@ var VariantMixin = require('sale.VariantMixin'); * @param {string} uri The uri to adapt */ VariantMixin._getUri = function (uri) { - if (this.isWebsite){ + if (this.isWebsite) { return uri + '_website'; } else { return uri; } }; +const originalOnChangeCombination = VariantMixin._onChangeCombination; +VariantMixin._onChangeCombination = function (ev, $parent, combination) { + const $pricePerUom = $parent.find(".o_base_unit_price:first .oe_currency_value"); + if ($pricePerUom) { + if (combination.is_combination_possible !== false && combination.base_unit_price != 0) { + $pricePerUom.parents(".o_base_unit_price_wrapper").removeClass("d-none"); + $pricePerUom.text(this._priceToStr(combination.base_unit_price)); + $parent.find(".oe_custom_base_unit:first").text(combination.base_unit_name); + } else { + $pricePerUom.parents(".o_base_unit_price_wrapper").addClass("d-none"); + } + } + originalOnChangeCombination.apply(this, [ev, $parent, combination]); +}; const originalToggleDisable = VariantMixin._toggleDisable; /** diff --git a/addons/website_sale/views/base_unit_view.xml b/addons/website_sale/views/base_unit_view.xml new file mode 100644 index 00000000000..1999c18428e --- /dev/null +++ b/addons/website_sale/views/base_unit_view.xml @@ -0,0 +1,12 @@ + + + + Base Units + website.base.unit + tree,form + + + + + diff --git a/addons/website_sale/views/product_views.xml b/addons/website_sale/views/product_views.xml index 54644316fd5..04275f7d4ed 100644 --- a/addons/website_sale/views/product_views.xml +++ b/addons/website_sale/views/product_views.xml @@ -98,6 +98,43 @@ + + product.template.product.only.website.form + product.template + + + + + + + + + product.product.normal.view.website + product.product + + + + + + + product.template.product.website.form product.template @@ -133,6 +170,16 @@ product.product + + diff --git a/addons/website_sale/views/res_config_settings_views.xml b/addons/website_sale/views/res_config_settings_views.xml index c2d093e879d..5c60ce74489 100644 --- a/addons/website_sale/views/res_config_settings_views.xml +++ b/addons/website_sale/views/res_config_settings_views.xml @@ -101,6 +101,18 @@ + +
+
+ +
+
+
+

Pricing

diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 3b68bf5fde4..f4f9849d446 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -571,6 +571,11 @@ + +