[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 <kzh@odoo.com>
Co-authored-by: Jeremy Kersten <jke@odoo.com>
This commit is contained in:
Kamen Zhekov
2021-09-02 10:41:51 +00:00
committed by Jeremy Kersten
co-authored by Jeremy Kersten
parent e0f0b86a9b
commit 8fc2e229f9
16 changed files with 307 additions and 10 deletions
+9 -5
View File
@@ -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.
+1
View File
@@ -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',
+1
View File
@@ -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
+10
View File
@@ -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)
+82 -1
View File
@@ -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()
@@ -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
@@ -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")
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
23 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
24 access_ecom_extra_fields_public access_ecom_extra_field public model_website_sale_extra_field 1 0 0 0
25 access_ecom_extra_fields_publisher access_ecom_extra_field publisher model_website_sale_extra_field website.group_website_publisher 1 1 1 1
26 access_website_base_unit_public website.base.unit public model_website_base_unit 1 0 0 0
27 access_website_base_unit_sale_manager website.base.unit sale manager model_website_base_unit sales_team.group_sale_manager 1 1 1 1
@@ -12,6 +12,11 @@
<field name="perm_unlink" eval="False"/>
</record>
<record id="group_show_uom_price" model="res.groups">
<field name="name">UOM Price Display for eCommerce</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="sales_team.group_sale_manager" model="res.groups">
<field name="implied_ids" eval="[(4, ref('website.group_website_publisher'))]"/>
</record>
@@ -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;
/**
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id='base_unit_action' model='ir.actions.act_window'>
<field name="name">Base Units</field>
<field name="res_model">website.base.unit</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem name="Base Units" id="website_base_unit_menu" action="base_unit_action"
parent="website_sale.menu_product_settings" sequence="5" groups="website_sale.group_show_uom_price"/>
</odoo>
@@ -98,6 +98,43 @@
</field>
</record>
<record model="ir.ui.view" id="product_template_only_website_form_view">
<field name="name">product.template.product.only.website.form</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='uom_id']" position="after">
<label for="base_unit_count" groups="website_sale.group_show_uom_price"/>
<div name="base_unit_price" groups="website_sale.group_show_uom_price" class="o_row">
<field name="base_unit_count" attrs="{'invisible': [('product_variant_count', '>', 1)]}" />
<field name="base_unit_id" options="{'no_open': True}" attrs="{'invisible': [('product_variant_count', '>', 1)]}" />
<span attrs="{'invisible': ['|', ('base_unit_price','=', 0), ('product_variant_count', '>', 1)]}">
(<field name="base_unit_price"/> / <field name="base_unit_name"/>)
</span>
<span class='text-muted' attrs="{'invisible': [('product_variant_count', '&lt;=', 1)]}">Based on variants</span>
</div>
</xpath>
</field>
</record>
<record model="ir.ui.view" id="product_product_normal_website_form_view">
<field name="name">product.product.normal.view.website</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_normal_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='uom_id']" position="after">
<label for="base_unit_count" groups="website_sale.group_show_uom_price"/>
<div name="base_unit_price" groups="website_sale.group_show_uom_price" class="o_row">
<field name="base_unit_count"/>
<field name="base_unit_id" options="{'no_open': True}"/>
<span attrs="{'invisible': [('base_unit_price','=', 0)]}">
(<field name="base_unit_price"/> / <field name="base_unit_name"/>)
</span>
</div>
</xpath>
</field>
</record>
<record model="ir.ui.view" id="product_template_form_view">
<field name="name">product.template.product.website.form</field>
<field name="model">product.template</field>
@@ -133,6 +170,16 @@
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_variant_easy_edit_view"/>
<field name="arch" type="xml">
<field name="standard_price" position="after">
<label for="base_unit_count" groups="website_sale.group_show_uom_price"/>
<div name="base_unit_price" groups="website_sale.group_show_uom_price" class="o_row">
<field name="base_unit_count"/>
<field name="base_unit_id" options="{'no_open': True}"/>
<span attrs="{'invisible': [('base_unit_price','=', 0)]}">
(<field name="base_unit_price"/> / <field name="base_unit_name"/>)
</span>
</div>
</field>
<sheet position="inside">
<group name="product_variant_images" string="Extra Variant Media">
<field name="product_variant_image_ids" class="o_website_sale_image_list" context="{'default_name': name}" mode="kanban" add-label="Add a Media" nolabel="1"/>
@@ -101,6 +101,18 @@
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="ecom_uom_price_option_setting">
<div class="o_setting_left_pane">
<field name="group_show_uom_price"/>
</div>
<div class="o_setting_right_pane">
<label for="group_show_uom_price"/>
<div class="text-muted">
Add price per base unit of measure on your Products
</div>
</div>
</div>
</div>
<h2>Pricing</h2>
+16 -3
View File
@@ -571,6 +571,11 @@
</xpath>
</template>
<template id="base_unit_price" name="Product Bas eunit price">
(<span class="o_base_unit_price" t-field="product.base_unit_price" t-options="{'display_currency': website.currency_id}"/>
/ <span class="oe_custom_base_unit" t-field="product.base_unit_name"/>)
</template>
<template id="product" name="Product" track="1">
<t t-set="combination" t-value="product._get_first_possible_combination()"/>
@@ -629,7 +634,12 @@
<form t-if="product._is_add_to_cart_possible()" action="/shop/cart/update" method="POST">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" />
<div class="js_product js_main_product mb-3">
<t t-call="website_sale.product_price" />
<div>
<t t-call="website_sale.product_price" />
<small class="ml-1 text-muted o_base_unit_price_wrapper d-none" groups="website_sale.group_show_uom_price">
<t t-call='website_sale.base_unit_price'/>
</small>
</div>
<t t-placeholder="select">
<input type="hidden" class="product_id" name="product_id" t-att-value="product_variant.id" />
<input type="hidden" class="product_template_id" name="product_template_id" t-att-value="product.id" />
@@ -787,7 +797,7 @@
</template>
<template id="product_price">
<div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price mt-2 mb-3">
<div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price d-inline-block mt-2 mb-3">
<h3 class="css_editable_mode_hidden">
<span class="oe_price" style="white-space: nowrap;" t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span itemprop="price" style="display:none;" t-esc="combination_info['price']"/>
@@ -1054,7 +1064,7 @@
</td>
<td class="text-center td-price" name="price">
<t t-set="combination" t-value="line.product_id.product_template_attribute_value_ids + line.product_no_variant_attribute_value_ids"/>
<t t-set="combination_info" t-value="line.product_id.product_tmpl_id._get_combination_info(combination, pricelist=website_sale_order.pricelist_id)"/>
<t t-set="combination_info" t-value="line.product_id.product_tmpl_id._get_combination_info(combination, pricelist=website_sale_order.pricelist_id, add_qty=line.product_uom_qty)"/>
<t t-set="list_price_converted" t-value="website.currency_id._convert(combination_info['list_price'], website_sale_order.currency_id, website_sale_order.company_id, date)"/>
<t groups="account.group_show_line_subtotals_tax_excluded" t-if="(website_sale_order.pricelist_id.discount_policy == 'without_discount' and website_sale_order.currency_id.compare_amounts(list_price_converted, line.price_reduce_taxexcl) == 1) or website_sale_order.currency_id.compare_amounts(line.price_unit, line.price_reduce) == 1" name="order_line_discount">
@@ -1065,6 +1075,9 @@
<del t-attf-class="#{'text-danger mr8'}" style="white-space: nowrap;" t-esc="list_price_converted" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" />
</t>
<span t-field="line.price_reduce_taxinc" style="white-space: nowrap;" t-options="{'widget': 'monetary', 'display_currency': website_sale_order.currency_id}" groups="account.group_show_line_subtotals_tax_included" />
<small t-if="not line._is_not_sellable_line() and line.product_id.base_unit_price" class="cart_product_base_unit_price d-block text-muted" groups="website_sale.group_show_uom_price">
<t t-call='website_sale.base_unit_price'><t t-set='product' t-value='line.product_id' /></t>
</small>
</td>
<td class="td-action">
<a href='#' aria-label="Remove from cart" title="Remove from cart" class='js_delete_product no-decoration'> <small><i class='fa fa-trash-o'></i></small></a>
@@ -129,6 +129,9 @@
<strong>Price:</strong>
<del t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" />
<span t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"></span>
<small class="d-block text-muted" groups="website_sale.group_show_uom_price" t-if="product.base_unit_price">
<t t-call='website_sale.base_unit_price'><t t-set='product' t-value='line.product_id' /></t>
</small>
</span>
<form action="/shop/cart/update" method="post" class="text-center">
@@ -187,6 +187,9 @@
<td class="align-middle">
<t t-set="combination_info" t-value="wish.product_id._get_combination_info_variant()"/>
<t t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}"/>
<small t-if="wish.product_id.base_unit_price" class="cart_product_base_unit_price d-block text-muted" groups="website_sale.group_show_uom_price">
<t t-call='website_sale.base_unit_price'><t t-set='product' t-value='wish.product_id' /></t>
</small>
</td>
<td class='text-center td-wish-btn align-middle'>
<input name="product_id" t-att-value="wish.product_id.id" type="hidden"/>