product/models/product_pricelist_item.py

463 lines
21 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools, _
from odoo.exceptions import ValidationError
from odoo.tools import format_datetime, formatLang
class PricelistItem(models.Model):
_name = "product.pricelist.item"
_description = "Pricelist Rule"
_order = "applied_on, min_quantity desc, categ_id desc, id desc"
_check_company_auto = True
def _default_pricelist_id(self):
return self.env['product.pricelist'].search([
'|', ('company_id', '=', False),
('company_id', '=', self.env.company.id)], limit=1)
pricelist_id = fields.Many2one(
comodel_name='product.pricelist',
string="Pricelist",
index=True, ondelete='cascade',
required=True,
default=_default_pricelist_id)
company_id = fields.Many2one(related='pricelist_id.company_id', store=True)
currency_id = fields.Many2one(related='pricelist_id.currency_id', store=True)
date_start = fields.Datetime(
string="Start Date",
help="Starting datetime for the pricelist item validation\n"
"The displayed value depends on the timezone set in your preferences.")
date_end = fields.Datetime(
string="End Date",
help="Ending datetime for the pricelist item validation\n"
"The displayed value depends on the timezone set in your preferences.")
min_quantity = fields.Float(
string="Min. Quantity",
default=0,
digits='Product Unit of Measure',
help="For the rule to apply, bought/sold quantity must be greater "
"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(
selection=[
('3_global', "All Products"),
('2_product_category', "Product Category"),
('1_product', "Product"),
('0_product_variant', "Product Variant"),
],
string="Apply On",
default='3_global',
required=True,
help="Pricelist Item applicable on selected option")
categ_id = fields.Many2one(
comodel_name='product.category',
string="Product Category",
ondelete='cascade',
help="Specify a product category if this rule only applies to products belonging to this category or its children categories. Keep empty otherwise.")
product_tmpl_id = fields.Many2one(
comodel_name='product.template',
string="Product",
ondelete='cascade', check_company=True,
help="Specify a template if this rule only applies to one product template. Keep empty otherwise.")
product_id = fields.Many2one(
comodel_name='product.product',
string="Product Variant",
ondelete='cascade', check_company=True,
help="Specify a product if this rule only applies to one product. Keep empty otherwise.")
base = fields.Selection(
selection=[
('list_price', 'Sales Price'),
('standard_price', 'Cost'),
('pricelist', 'Other Pricelist'),
],
string="Based on",
default='list_price',
required=True,
help="Base price for computation.\n"
"Sales Price: The base price will be the Sales Price.\n"
"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', check_company=True)
compute_price = fields.Selection(
selection=[
('fixed', "Fixed Price"),
('percentage', "Discount"),
('formula', "Formula"),
],
index=True, default='fixed', required=True)
fixed_price = fields.Float(string="Fixed Price", digits='Product Price')
percent_price = fields.Float(
string="Percentage Price",
help="You can apply a mark-up by setting a negative discount.")
price_discount = fields.Float(
string="Price Discount",
default=0,
digits=(16, 2),
help="You can apply a mark-up by setting a negative discount.")
price_round = fields.Float(
string="Price Rounding",
digits='Product Price',
help="Sets the price so that it is a multiple of this value.\n"
"Rounding is applied after the discount and before the surcharge.\n"
"To have prices that end in 9.99, set rounding 10, surcharge -0.01")
price_surcharge = fields.Float(
string="Price Surcharge",
digits='Product Price',
help="Specify the fixed amount to add or subtract (if negative) to the amount calculated with the discount.")
price_min_margin = fields.Float(
string="Min. Price Margin",
digits='Product Price',
help="Specify the minimum amount of margin over the base price.")
price_max_margin = fields.Float(
string="Max. Price Margin",
digits='Product Price',
help="Specify the maximum amount of margin over the base price.")
# functional fields used for usability purposes
name = fields.Char(
string="Name",
compute='_compute_name_and_price',
help="Explicit rule name for this pricelist line.")
price = fields.Char(
string="Price",
compute='_compute_name_and_price',
help="Explicit rule name for this pricelist line.")
rule_tip = fields.Char(compute='_compute_rule_tip')
#=== COMPUTE METHODS ===#
@api.depends('applied_on', 'categ_id', 'product_tmpl_id', 'product_id', 'compute_price', 'fixed_price', \
'pricelist_id', 'percent_price', 'price_discount', 'price_surcharge')
def _compute_name_and_price(self):
for item in self:
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.display_name)
else:
item.name = _("All Products")
if item.compute_price == 'fixed':
item.price = formatLang(
item.env, item.fixed_price, monetary=True, dp="Product Price", currency_obj=item.currency_id)
elif item.compute_price == 'percentage':
item.price = _("%s %% discount", item.percent_price)
else:
item.price = _("%(percentage)s %% discount and %(price)s surcharge", percentage=item.price_discount, price=item.price_surcharge)
@api.depends_context('lang')
@api.depends('compute_price', 'price_discount', 'price_surcharge', 'base', 'price_round')
def _compute_rule_tip(self):
base_selection_vals = {elem[0]: elem[1] for elem in self._fields['base']._description_selection(self.env)}
self.rule_tip = False
for item in self:
if item.compute_price != 'formula':
continue
base_amount = 100
discount_factor = (100 - item.price_discount) / 100
discounted_price = base_amount * discount_factor
if item.price_round:
discounted_price = tools.float_round(discounted_price, precision_rounding=item.price_round)
surcharge = tools.format_amount(item.env, item.price_surcharge, item.currency_id)
item.rule_tip = _(
"%(base)s with a %(discount)s %% discount and %(surcharge)s extra fee\n"
"Example: %(amount)s * %(discount_charge)s + %(price_surcharge)s%(total_amount)s",
base=base_selection_vals[item.base],
discount=item.price_discount,
surcharge=surcharge,
amount=tools.format_amount(item.env, 100, item.currency_id),
discount_charge=discount_factor,
price_surcharge=surcharge,
total_amount=tools.format_amount(
item.env, discounted_price + item.price_surcharge, item.currency_id),
)
#=== CONSTRAINT METHODS ===#
@api.constrains('base_pricelist_id', 'pricelist_id', 'base')
def _check_recursion(self):
if any(item.base == 'pricelist' and item.pricelist_id and item.pricelist_id == item.base_pricelist_id for item in self):
raise ValidationError(_('You cannot assign the Main Pricelist as Other Pricelist in PriceList Item'))
@api.constrains('date_start', 'date_end')
def _check_date_range(self):
for item in self:
if item.date_start and item.date_end and item.date_start >= item.date_end:
raise ValidationError(_('%s: end date (%s) should be greater than start date (%s)', item.display_name, format_datetime(self.env, item.date_end), format_datetime(self.env, item.date_start)))
return True
@api.constrains('price_min_margin', 'price_max_margin')
def _check_margin(self):
if any(item.price_min_margin > item.price_max_margin for item in self):
raise ValidationError(_('The minimum margin should be lower than the maximum margin.'))
@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"))
#=== ONCHANGE METHODS ===#
@api.onchange('compute_price')
def _onchange_compute_price(self):
if self.compute_price != 'fixed':
self.fixed_price = 0.0
if self.compute_price != 'percentage':
self.percent_price = 0.0
if self.compute_price != 'formula':
self.update({
'base': 'list_price',
'price_discount': 0.0,
'price_surcharge': 0.0,
'price_round': 0.0,
'price_min_margin': 0.0,
'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 _onchange_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.onchange('price_round')
def _onchange_price_round(self):
if any(item.price_round and item.price_round < 0.0 for item in self):
raise ValidationError(_("The rounding method must be strictly positive."))
#=== CRUD METHODS ===#
@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().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))
return super().write(values)
#=== BUSINESS METHODS ===#
def _is_applicable_for(self, product, qty_in_product_uom):
"""Check whether the current rule is valid for the given product & qty.
Note: self.ensure_one()
:param product: product record (product.product/product.template)
:param float qty_in_product_uom: quantity, expressed in product UoM
:returns: Whether rules is valid or not
:rtype: bool
"""
self.ensure_one()
product.ensure_one()
res = True
is_product_template = product._name == 'product.template'
if self.min_quantity and qty_in_product_uom < self.min_quantity:
res = False
elif self.applied_on == "2_product_category":
if (
product.categ_id != self.categ_id
and not product.categ_id.parent_path.startswith(self.categ_id.parent_path)
):
res = False
else:
# Applied on a specific product template/variant
if is_product_template:
if self.applied_on == "1_product" and product.id != self.product_tmpl_id.id:
res = False
elif self.applied_on == "0_product_variant" and not (
product.product_variant_count == 1
and product.product_variant_id.id == self.product_id.id
):
# product self acceptable on template if has only one variant
res = False
else:
if self.applied_on == "1_product" and product.product_tmpl_id.id != self.product_tmpl_id.id:
res = False
elif self.applied_on == "0_product_variant" and product.id != self.product_id.id:
res = False
return res
def _compute_price(self, product, quantity, uom, date, currency=None):
"""Compute the unit price of a product in the context of a pricelist application.
Note: self and self.ensure_one()
:param product: recordset of product (product.product/product.template)
:param float qty: quantity of products requested (in given uom)
:param uom: unit of measure (uom.uom record)
:param datetime date: date to use for price computation and currency conversions
:param currency: currency (for the case where self is empty)
:returns: price according to pricelist rule or the product price, expressed in the param
currency, the pricelist currency or the company currency
:rtype: float
"""
self and self.ensure_one() # self is at most one record
product.ensure_one()
uom.ensure_one()
currency = currency or self.currency_id or self.env.company.currency_id
currency.ensure_one()
# Pricelist specific values are specified according to product UoM
# and must be multiplied according to the factor between uoms
product_uom = product.uom_id
if product_uom != uom:
convert = lambda p: product_uom._compute_price(p, uom)
else:
convert = lambda p: p
if self.compute_price == 'fixed':
price = convert(self.fixed_price)
elif self.compute_price == 'percentage':
base_price = self._compute_base_price(product, quantity, uom, date, currency)
price = (base_price - (base_price * (self.percent_price / 100))) or 0.0
elif self.compute_price == 'formula':
base_price = self._compute_base_price(product, quantity, uom, date, currency)
# complete formula
price_limit = base_price
price = (base_price - (base_price * (self.price_discount / 100))) or 0.0
if self.price_round:
price = tools.float_round(price, precision_rounding=self.price_round)
if self.price_surcharge:
price += convert(self.price_surcharge)
if self.price_min_margin:
price = max(price, price_limit + convert(self.price_min_margin))
if self.price_max_margin:
price = min(price, price_limit + convert(self.price_max_margin))
else: # empty self, or extended pricelist price computation logic
price = self._compute_base_price(product, quantity, uom, date, currency)
return price
def _compute_base_price(self, product, quantity, uom, date, currency):
""" Compute the base price for a given rule
:param product: recordset of product (product.product/product.template)
:param float qty: quantity of products requested (in given uom)
:param uom: unit of measure (uom.uom record)
:param datetime date: date to use for price computation and currency conversions
:param currency: currency in which the returned price must be expressed
:returns: base price, expressed in provided pricelist currency
:rtype: float
"""
currency.ensure_one()
rule_base = self.base or 'list_price'
if rule_base == 'pricelist' and self.base_pricelist_id:
price = self.base_pricelist_id._get_product_price(
product, quantity, currency=self.base_pricelist_id.currency_id, uom=uom, date=date
)
src_currency = self.base_pricelist_id.currency_id
elif rule_base == "standard_price":
src_currency = product.cost_currency_id
price = product._price_compute(rule_base, uom=uom, date=date)[product.id]
else: # list_price
src_currency = product.currency_id
price = product._price_compute(rule_base, uom=uom, date=date)[product.id]
if src_currency != currency:
price = src_currency._convert(price, currency, self.env.company, date, round=False)
return price
def _compute_price_before_discount(self, *args, **kwargs):
"""Compute the base price of the lowest pricelist rule whose pricelist discount_policy
is set to show the discount to the customer.
:param product: recordset of product (product.product/product.template)
:param float qty: quantity of products requested (in given uom)
:param uom: unit of measure (uom.uom record)
:param datetime date: date to use for price computation and currency conversions
:param currency: currency in which the returned price must be expressed
:returns: base price, expressed in provided pricelist currency
:rtype: float
"""
pricelist_rule = self
if pricelist_rule and pricelist_rule.pricelist_id.discount_policy == 'without_discount':
pricelist_item = pricelist_rule
# Find the lowest pricelist rule whose pricelist is configured to show the discount
# to the customer.
while (
pricelist_item.base == 'pricelist'
and pricelist_item.base_pricelist_id.discount_policy == 'without_discount'
):
rule_id = pricelist_item.base_pricelist_id._get_product_rule(*args, **kwargs)
pricelist_item = self.env['product.pricelist.item'].browse(rule_id)
pricelist_rule = pricelist_item
return pricelist_rule._compute_base_price(*args, **kwargs)