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 @@ + +