-
+
-
+
Set multiple prices per product, automated discounts, etc.
-
diff --git a/addons/product/models/product.py b/addons/product/models/product.py
index a0bfee330d7..83e39d887b0 100644
--- a/addons/product/models/product.py
+++ b/addons/product/models/product.py
@@ -113,8 +113,7 @@ class ProductProduct(models.Model):
volume = fields.Float('Volume')
weight = fields.Float('Weight', digits='Stock Weight')
- pricelist_item_ids = fields.Many2many(
- 'product.pricelist.item', 'Pricelist Items', compute='_get_pricelist_items')
+ pricelist_item_count = fields.Integer("Number of price rules", compute="_compute_variant_item_count")
packaging_ids = fields.One2many(
'product.packaging', 'product_id', 'Product Packages',
@@ -317,12 +316,12 @@ class ProductProduct(models.Model):
else:
product.product_template_attribute_value_ids += values_per_template[product.product_tmpl_id.id][pav.id]
- def _get_pricelist_items(self):
+ def _compute_variant_item_count(self):
for product in self:
- product.pricelist_item_ids = self.env['product.pricelist.item'].search([
- '|',
- ('product_id', '=', product.id),
- ('product_tmpl_id', '=', product.product_tmpl_id.id)]).ids
+ domain = ['|',
+ '&', ('product_tmpl_id', '=', product.product_tmpl_id.id), ('applied_on', '=', '1_product'),
+ '&', ('product_id', '=', product.id), ('applied_on', '=', '0_product_variant')]
+ product.pricelist_item_count = self.env['product.pricelist.item'].search_count(domain)
@api.constrains('attribute_value_ids')
def _check_attribute_value_ids(self):
@@ -581,6 +580,25 @@ class ProductProduct(models.Model):
return _('Products: ') + self.env['product.category'].browse(self._context['categ_id']).name
return res
+ def open_pricelist_rules(self):
+ self.ensure_one()
+ domain = ['|',
+ '&', ('product_tmpl_id', '=', self.product_tmpl_id.id), ('applied_on', '=', '1_product'),
+ '&', ('product_id', '=', self.id), ('applied_on', '=', '0_product_variant')]
+ return {
+ 'name': _('Price Rules'),
+ 'view_mode': 'tree,form',
+ 'views': [(self.env.ref('product.product_pricelist_item_tree_view_from_product').id, 'tree'), (False, 'form')],
+ 'res_model': 'product.pricelist.item',
+ 'type': 'ir.actions.act_window',
+ 'target': 'current',
+ 'domain': domain,
+ 'context': {
+ 'default_product_id': self.id,
+ 'default_applied_on': '0_product_variant',
+ }
+ }
+
def open_product_template(self):
""" Utility method used to add an "Open Template" button in product views """
self.ensure_one()
diff --git a/addons/product/models/product_pricelist.py b/addons/product/models/product_pricelist.py
index dd0dbec8fab..584ec39f27d 100644
--- a/addons/product/models/product_pricelist.py
+++ b/addons/product/models/product_pricelist.py
@@ -7,7 +7,6 @@ from odoo import api, fields, models, tools, _
from odoo.exceptions import UserError, ValidationError
-
class Pricelist(models.Model):
_name = "product.pricelist"
_description = "Pricelist"
@@ -46,7 +45,7 @@ class Pricelist(models.Model):
FROM ((
SELECT pr.id, pr.name
FROM product_pricelist pr JOIN
- res_currency cur ON
+ res_currency cur ON
(pr.currency_id = cur.id)
WHERE pr.name || ' (' || cur.name || ')' = %(name)s
)
@@ -59,7 +58,7 @@ class Pricelist(models.Model):
tr.name = 'product.pricelist,name' AND
tr.lang = %(lang)s
) JOIN
- res_currency cur ON
+ res_currency cur ON
(pr.currency_id = cur.id)
WHERE tr.value || ' (' || cur.name || ')' = %(name)s
)
@@ -374,20 +373,22 @@ class ResCountryGroup(models.Model):
class PricelistItem(models.Model):
_name = "product.pricelist.item"
- _description = "Pricelist Item"
+ _description = "Pricelist Rule"
_order = "applied_on, min_quantity desc, categ_id desc, id desc"
# NOTE: if you change _order on this model, make sure it matches the SQL
# query built in _compute_price_rule() above in this file to avoid
# inconstencies and undeterministic issues.
- def _get_default_applied_on(self):
- return self.env.user.has_group('product.group_product_pricelist') and '1_product' or '3_global'
+ def _default_pricelist_id(self):
+ return self.env['product.pricelist'].search([
+ '|', ('company_id', '=', False),
+ ('company_id', '=', self.env.company.id)], limit=1)
product_tmpl_id = fields.Many2one(
- 'product.template', 'Product Template', ondelete='cascade',
+ 'product.template', 'Product', ondelete='cascade',
help="Specify a template if this rule only applies to one product template. Keep empty otherwise.")
product_id = fields.Many2one(
- 'product.product', 'Product', ondelete='cascade',
+ 'product.product', 'Product Variant', ondelete='cascade',
help="Specify a product if this rule only applies to one product. Keep empty otherwise.")
categ_id = fields.Many2one(
'product.category', 'Product Category', ondelete='cascade',
@@ -398,11 +399,11 @@ class PricelistItem(models.Model):
"than or equal to the minimum quantity specified in this field.\n"
"Expressed in the default unit of measure of the product.")
applied_on = fields.Selection([
- ('3_global', 'Global'),
+ ('3_global', 'All Products'),
('2_product_category', ' Product Category'),
('1_product', 'Product'),
('0_product_variant', 'Product Variant')], "Apply On",
- default=_get_default_applied_on, required=True,
+ default='3_global', required=True,
help='Pricelist Item applicable on selected option')
base = fields.Selection([
('list_price', 'Sales Price'),
@@ -414,7 +415,7 @@ class PricelistItem(models.Model):
'Cost Price : The base price will be the cost price.\n'
'Other Pricelist : Computation of the base price based on another Pricelist.')
base_pricelist_id = fields.Many2one('product.pricelist', 'Other Pricelist')
- pricelist_id = fields.Many2one('product.pricelist', 'Pricelist', index=True, ondelete='cascade', required=True)
+ pricelist_id = fields.Many2one('product.pricelist', 'Pricelist', index=True, ondelete='cascade', required=True, default=_default_pricelist_id)
price_surcharge = fields.Float(
'Price Surcharge', digits='Product Price',
help='Specify the fixed amount to add or substract(if negative) to the amount calculated with the discount.')
@@ -436,13 +437,15 @@ class PricelistItem(models.Model):
currency_id = fields.Many2one(
'res.currency', 'Currency',
readonly=True, related='pricelist_id.currency_id', store=True)
+ active = fields.Boolean(
+ readonly=True, related="pricelist_id.active", store=True)
date_start = fields.Date('Start Date', help="Starting date for the pricelist item validation")
date_end = fields.Date('End Date', help="Ending valid for the pricelist item validation")
compute_price = fields.Selection([
- ('fixed', 'Fix Price'),
+ ('fixed', 'Fixed Price'),
('percentage', 'Percentage (discount)'),
- ('formula', 'Formula')], index=True, default='fixed')
- fixed_price = fields.Float('Fixed Price', digits='Product Price')
+ ('formula', 'Formula')], index=True, default='fixed', required=True)
+ fixed_price = fields.Monetary('Fixed Price')
percent_price = fields.Float('Percentage Price')
# functional fields used for usability purposes
name = fields.Char(
@@ -464,35 +467,39 @@ class PricelistItem(models.Model):
raise ValidationError(_('The minimum margin should be lower than the maximum margin.'))
return True
- @api.depends('categ_id', 'product_tmpl_id', 'product_id', 'compute_price', 'fixed_price', \
+ @api.constrains('product_id', 'product_tmpl_id', 'categ_id')
+ def _check_product_consistency(self):
+ for item in self:
+ if item.applied_on == "2_product_category" and not item.categ_id:
+ raise ValidationError(_("Please specify the category for which this rule should be applied"))
+ elif item.applied_on == "1_product" and not item.product_tmpl_id:
+ raise ValidationError(_("Please specify the product for which this rule should be applied"))
+ elif item.applied_on == "0_product_variant" and not item.product_id:
+ raise ValidationError(_("Please specify the product variant for which this rule should be applied"))
+
+ @api.depends('applied_on', 'categ_id', 'product_tmpl_id', 'product_id', 'compute_price', 'fixed_price', \
'pricelist_id', 'percent_price', 'price_discount', 'price_surcharge')
def _get_pricelist_item_name_price(self):
for item in self:
- if item.categ_id:
- item.name = _("Category: %s") % (item.categ_id.name)
- elif item.product_tmpl_id:
- item.name = item.product_tmpl_id.name
- elif item.product_id:
- item.name = item.product_id.display_name.replace('[%s]' % item.product_id.code, '')
+ if item.categ_id and item.applied_on == '2_product_category':
+ item.name = _("Category: %s") % (item.categ_id.display_name)
+ elif item.product_tmpl_id and item.applied_on == '1_product':
+ item.name = _("Product: %s") % (item.product_tmpl_id.display_name)
+ elif item.product_id and item.applied_on == '0_product_variant':
+ item.name = _("Variant: %s") % (item.product_id.with_context(display_default_code=False).display_name)
else:
item.name = _("All Products")
if item.compute_price == 'fixed':
- item.price = ("%s %s") % (item.fixed_price, item.pricelist_id.currency_id.name)
+ if item.currency_id.position == 'after':
+ item.price = "%s %s" % (item.fixed_price, item.currency_id.symbol)
+ else:
+ item.price = "%s %s" % (item.currency_id.symbol, item.fixed_price)
elif item.compute_price == 'percentage':
item.price = _("%s %% discount") % (item.percent_price)
else:
item.price = _("%s %% discount and %s surcharge") % (item.price_discount, item.price_surcharge)
- @api.onchange('applied_on')
- def _onchange_applied_on(self):
- if self.applied_on != '0_product_variant':
- self.product_id = False
- if self.applied_on != '1_product':
- self.product_tmpl_id = False
- if self.applied_on != '2_product_category':
- self.categ_id = False
-
@api.onchange('compute_price')
def _onchange_compute_price(self):
if self.compute_price != 'fixed':
@@ -508,14 +515,65 @@ class PricelistItem(models.Model):
'price_max_margin': 0.0,
})
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ has_product_id = self.filtered('product_id')
+ for item in has_product_id:
+ item.product_tmpl_id = item.product_id.product_tmpl_id
+ if self.env.context.get('default_applied_on', False) == '1_product':
+ # If a product variant is specified, apply on variants instead
+ # Reset if product variant is removed
+ has_product_id.update({'applied_on': '0_product_variant'})
+ (self - has_product_id).update({'applied_on': '1_product'})
+
+ @api.onchange('product_tmpl_id')
+ def _onchange_product_tmpl_id(self):
+ has_tmpl_id = self.filtered('product_tmpl_id')
+ for item in has_tmpl_id:
+ if item.product_id and item.product_id.product_tmpl_id != item.product_tmpl_id:
+ item.product_id = None
+
+ @api.onchange('product_id', 'product_tmpl_id', 'categ_id')
+ def _onchane_rule_content(self):
+ if not self.user_has_groups('product.group_sale_pricelist') and not self.env.context.get('default_applied_on', False):
+ # If advanced pricelists are disabled (applied_on field is not visible)
+ # AND we aren't coming from a specific product template/variant.
+ variants_rules = self.filtered('product_id')
+ template_rules = (self-variants_rules).filtered('product_tmpl_id')
+ variants_rules.update({'applied_on': '0_product_variant'})
+ template_rules.update({'applied_on': '1_product'})
+ (self-variants_rules-template_rules).update({'applied_on': '3_global'})
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for values in vals_list:
+ if values.get('applied_on', False):
+ # Ensure item consistency for later searches.
+ applied_on = values['applied_on']
+ if applied_on == '3_global':
+ values.update(dict(product_id=None, product_tmpl_id=None, categ_id=None))
+ elif applied_on == '2_product_category':
+ values.update(dict(product_id=None, product_tmpl_id=None))
+ elif applied_on == '1_product':
+ values.update(dict(product_id=None, categ_id=None))
+ elif applied_on == '0_product_variant':
+ values.update(dict(categ_id=None))
+ return super(PricelistItem, self).create(vals_list)
+
def write(self, values):
+ if values.get('applied_on', False):
+ # Ensure item consistency for later searches.
+ applied_on = values['applied_on']
+ if applied_on == '3_global':
+ values.update(dict(product_id=None, product_tmpl_id=None, categ_id=None))
+ elif applied_on == '2_product_category':
+ values.update(dict(product_id=None, product_tmpl_id=None))
+ elif applied_on == '1_product':
+ values.update(dict(product_id=None, categ_id=None))
+ elif applied_on == '0_product_variant':
+ values.update(dict(categ_id=None))
res = super(PricelistItem, self).write(values)
# When the pricelist changes we need the product.template price
# to be invalided and recomputed.
self.invalidate_cache()
return res
-
- @api.onchange('product_tmpl_id')
- def _onchange_product_tmpl_id(self):
- if self.user_has_groups('product.group_product_variant'):
- self.product_id = False
diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py
index 17e8594b006..1fb7484598c 100644
--- a/addons/product/models/product_template.py
+++ b/addons/product/models/product_template.py
@@ -148,11 +148,17 @@ class ProductTemplate(models.Model):
'Internal Reference', compute='_compute_default_code',
inverse='_set_default_code', store=True)
- item_ids = fields.One2many('product.pricelist.item', 'product_tmpl_id', 'Pricelist Items')
+ pricelist_item_count = fields.Integer("Number of price rules", compute="_compute_item_count")
can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed', store=True)
has_configurable_attributes = fields.Boolean("Is a configurable product", compute='_compute_has_configurable_attributes', store=True)
+ def _compute_item_count(self):
+ for template in self:
+ # Pricelist item count counts the rules applicable on current template or on its variants.
+ template.pricelist_item_count = template.env['product.pricelist.item'].search_count([
+ '|', ('product_tmpl_id', '=', template.id), ('product_id', 'in', template.product_variant_ids.ids)])
+
@api.depends('image_1920', 'image_1024')
def _compute_can_image_1024_be_zoomed(self):
for template in self:
@@ -454,6 +460,26 @@ class ProductTemplate(models.Model):
'', args=[('id', 'in', list(searched_ids))],
operator='ilike', limit=limit, name_get_uid=name_get_uid)
+ def open_pricelist_rules(self):
+ self.ensure_one()
+ domain = ['|',
+ ('product_tmpl_id', '=', self.id),
+ ('product_id', 'in', self.product_variant_ids.ids)]
+ return {
+ 'name': _('Price Rules'),
+ 'view_mode': 'tree,form',
+ 'views': [(self.env.ref('product.product_pricelist_item_tree_view_from_product').id, 'tree'), (False, 'form')],
+ 'res_model': 'product.pricelist.item',
+ 'type': 'ir.actions.act_window',
+ 'target': 'current',
+ 'domain': domain,
+ 'context': {
+ 'default_product_tmpl_id': self.id,
+ 'default_applied_on': '1_product',
+ 'product_without_variants': self.product_variant_count == 1,
+ },
+ }
+
def price_compute(self, price_type, uom=False, currency=False, company=False):
# TDE FIXME: delegate to template or not ? fields are reencoded here ...
# compatibility about context keys used a bit everywhere in the code
diff --git a/addons/product/models/res_config_settings.py b/addons/product/models/res_config_settings.py
index 6ff32bd8f6b..3606c5806c7 100644
--- a/addons/product/models/res_config_settings.py
+++ b/addons/product/models/res_config_settings.py
@@ -14,14 +14,17 @@ class ResConfigSettings(models.TransientModel):
module_sale_product_matrix = fields.Boolean("Product Grid Configurator")
group_stock_packaging = fields.Boolean('Product Packagings',
implied_group='product.group_stock_packaging')
- group_sale_pricelist = fields.Boolean("Use pricelists to adapt your price per customers",
+ group_product_pricelist = fields.Boolean("Pricelists",
+ implied_group='product.group_product_pricelist')
+ group_sale_pricelist = fields.Boolean("Advanced Pricelists",
implied_group='product.group_sale_pricelist',
help="""Allows to manage different prices based on rules per category of customers.
Example: 10% for retailers, promotion of 5 EUR on this product, etc.""")
- group_product_pricelist = fields.Boolean("Show pricelists On Products",
- implied_group='product.group_product_pricelist')
- group_pricelist_item = fields.Boolean("Show pricelists to customers",
- implied_group='product.group_pricelist_item')
+ product_pricelist_setting = fields.Selection([
+ ('basic', 'Multiple prices per product'),
+ ('advanced', 'Advanced price rules (discounts, formulas)')
+ ], default='basic', string="Pricelists Method", config_parameter='product.product_pricelist_setting',
+ help="Multiple prices: Pricelists with fixed price rules by product,\nAdvanced rules: enables advanced price rules for pricelists.")
product_weight_in_lbs = fields.Selection([
('0', 'Kilogram'),
('1', 'Pound'),
@@ -47,6 +50,23 @@ class ResConfigSettings(models.TransientModel):
if self.module_sale_product_configurator and not self.group_product_variant:
self.group_product_variant = True
+ @api.onchange('group_multi_currency')
+ def _onchange_group_multi_currency(self):
+ if self.group_product_pricelist and not self.group_sale_pricelist and self.group_multi_currency:
+ self.group_sale_pricelist = True
+
+ @api.onchange('group_product_pricelist')
+ def _onchange_group_sale_pricelist(self):
+ if not self.group_product_pricelist and self.group_sale_pricelist:
+ self.group_sale_pricelist = False
+
+ @api.onchange('product_pricelist_setting')
+ def _onchange_product_pricelist_setting(self):
+ if self.product_pricelist_setting == 'basic':
+ self.group_sale_pricelist = False
+ else:
+ self.group_sale_pricelist = True
+
def set_values(self):
super(ResConfigSettings, self).set_values()
if not self.group_discount_per_so_line:
diff --git a/addons/product/security/product_security.xml b/addons/product/security/product_security.xml
index bb995448b1f..f10acf38bd2 100644
--- a/addons/product/security/product_security.xml
+++ b/addons/product/security/product_security.xml
@@ -7,11 +7,6 @@
-
- Manage Pricelist Items
-
-
-
Pricelists On Product
diff --git a/addons/product/tests/test_product_pricelist.py b/addons/product/tests/test_product_pricelist.py
index 43db6f7c49b..cb13db1b327 100644
--- a/addons/product/tests/test_product_pricelist.py
+++ b/addons/product/tests/test_product_pricelist.py
@@ -33,14 +33,14 @@ class TestProductPricelist(TransactionCase):
}), (0, 0, {
'name': '10% Discount on Assemble Computer',
'applied_on': '1_product',
- 'product_id': self.ipad_retina_display.id,
+ 'product_tmpl_id': self.ipad_retina_display.product_tmpl_id.id,
'compute_price': 'formula',
'base': 'list_price',
'price_discount': 10
}), (0, 0, {
'name': '1 surchange on Laptop',
'applied_on': '1_product',
- 'product_id': self.laptop_E5023.id,
+ 'product_tmpl_id': self.laptop_E5023.product_tmpl_id.id,
'compute_price': 'formula',
'base': 'list_price',
'price_surcharge': 1
@@ -54,7 +54,7 @@ class TestProductPricelist(TransactionCase):
'price_discount': 5
}), (0, 0, {
'name': '30% Discount on all products',
- 'applied_on': '0_product_variant',
+ 'applied_on': '3_global',
'date_start': '2011-12-27',
'date_end': '2011-12-31',
'compute_price': 'formula',
diff --git a/addons/product/views/product_pricelist_views.xml b/addons/product/views/product_pricelist_views.xml
index ce292af8749..7a019c7480f 100644
--- a/addons/product/views/product_pricelist_views.xml
+++ b/addons/product/views/product_pricelist_views.xml
@@ -2,18 +2,87 @@
-
- product.pricelist.item.tree
+
+ product.pricelist.item.search
product.pricelist.item
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ product.pricelist.item.tree
+ product.pricelist.item
+ 10
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ product.pricelist.item.tree
+ product.pricelist.item
+ 100
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -23,61 +92,79 @@
product.pricelist.item
@@ -142,28 +229,44 @@
@@ -173,21 +276,25 @@
diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml
index cbf96de83f0..2e142610399 100644
--- a/addons/product/views/product_views.xml
+++ b/addons/product/views/product_views.xml
@@ -45,7 +45,20 @@