Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IMP] pricelist_refactor: support discounts and formula in pricelists for subscription and rental products #646

Draft
wants to merge 5 commits into
base: 18.0
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pricelist_refactor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
17 changes: 17 additions & 0 deletions pricelist_refactor/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
'name': 'Pricelist Refactor',
'version': '1.0',
'category': 'sale_management',
'depends': ['sale_subscription', 'sale_renting', 'product'],
'description': """
Refactors the pricelist functionality for rental and subscription products, introducing discount and
formula-based pricing rules.
""",
'data': [
'views/product_pricelist_views.xml',
'views/product_pricelist_item_views.xml',
'views/product_template_views.xml',
],
'installable': True,
'license': 'LGPL-3',
}
6 changes: 6 additions & 0 deletions pricelist_refactor/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from . import product_pricelist
from . import product_pricelist_item
from . import sale_order_line
from . import product_template
from . import product_product
from . import res_config_settings
96 changes: 96 additions & 0 deletions pricelist_refactor/models/product_pricelist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
from odoo import fields, models


class ProductPricelist(models.Model):
_inherit = 'product.pricelist'

item_ids = fields.One2many(
comodel_name='product.pricelist.item',
inverse_name='pricelist_id',
string="Pricing Rules",
domain=[
'&',
'|', ('product_tmpl_id', '=', None), ('product_tmpl_id.active', '=', True),
'|', ('product_id', '=', None), ('product_id.active', '=', True),
('plan_id', '=', None),
('recurrence_id', '=', None)
],
)

product_subscription_pricelist_ids = fields.One2many(
comodel_name='product.pricelist.item',
inverse_name='pricelist_id',
string="Recurring Pricing Rules",
domain=[
'&',
'|', ('product_tmpl_id', '=', None), ('product_tmpl_id.active', '=', True),
'|', ('product_id', '=', None), ('product_id.active', '=', True),
('plan_id', '!=', None),
]
)

product_rental_pricelist_ids = fields.One2many(
comodel_name='product.pricelist.item',
inverse_name='pricelist_id',
string="Rental Pricing Rules",
domain=[
'&',
'|', ('product_tmpl_id', '=', None), ('product_tmpl_id.active', '=', True),
'|', ('product_id', '=', None), ('product_id.active', '=', True),
('recurrence_id', '!=', None),
]
)

def _compute_price_rule(
self, products, quantity, currency=None, date=False, start_date=None, end_date=None, plan_id=None, uom=None, **kwargs
):
"""override method to compute price rule for rental and subscription."""
self and self.ensure_one() # self is at most one record
currency = currency or self.currency_id or self.env.company.currency_id
currency.ensure_one()
if not products:
return {}
if not date:
date = fields.Datetime.now()
results = {}
for product in products:
if self._enable_rental_price(start_date, end_date) and product.rent_ok:
Pricing = self.env['product.pricelist.item']
if start_date and end_date:
pricelist_id = product._get_best_pricing_rule(
quantity, date, uom=uom, start_date=start_date, end_date=end_date, pricelist=self, currency=currency
)
duration_vals = Pricing._compute_duration_vals(start_date, end_date)
duration = pricelist_id and duration_vals[pricelist_id.recurrence_id.unit or 'day'] or 0
else:
pricelist_id = Pricing._get_first_suitable_pricing(product, self)
duration = pricelist_id.recurrence_id.duration
if pricelist_id:
price = pricelist_id._compute_price_rental(duration, pricelist_id.recurrence_id.unit, product, quantity, date, start_date, end_date, uom=uom)
elif product.default_rent_unit:
duration_vals = Pricing._compute_duration_vals(start_date, end_date)
duration = product.default_rent_unit and duration_vals[pricelist_id.recurrence_id.unit or 'day'] or 0
price = product._compute_rental_default(duration, product.default_rent_unit)
elif product._name == 'product.product':
price = product.lst_price
else:
price = product.list_price
results[product.id] = pricelist_id.currency_id._convert(
price, currency, self.env.company, date
), pricelist_id.id
elif product.recurring_invoice:
pricelist_item_id = self.env['product.pricelist.item']._get_first_suitable_recurring_pricing(
product, quantity, date, plan=plan_id, pricelist=self
)
results[product.id] = pricelist_item_id._compute_price(product, quantity, uom, date, plan_id=plan_id), pricelist_item_id.id
price_computed_products = self.env[products._name].browse(results.keys())
return {
**results,
**super()._compute_price_rule(
products - price_computed_products, quantity, currency=currency, date=date, **kwargs
),
}

def _enable_rental_price(self, start_date, end_date):
"""Determine if rental pricelist_id should be used."""
return bool(start_date and end_date)
243 changes: 243 additions & 0 deletions pricelist_refactor/models/product_pricelist_item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
from odoo import api, fields, models, tools
import math
from dateutil.relativedelta import relativedelta

PERIOD_RATIO = {
'hour': 1,
'day': 24,
'week': 24 * 7,
'month': 24 * 31,
'year': 24 * 31 * 12,
}


class ProductPricelistItem(models.Model):
_inherit = 'product.pricelist.item'

active = fields.Boolean(string="Active", default=True)
plan_id = fields.Many2one(comodel_name='sale.subscription.plan', string="Recurring Plan")
recurrence_id = fields.Many2one(comodel_name='sale.temporal.recurrence', string="Renting Period")

@api.model
def _get_first_suitable_recurring_pricing(self, product, product_uom_qty, date, plan=None, pricelist=None):
""" Get a suitable pricing for given product and pricelist."""
product_sudo = product
is_product_template = product_sudo._name == "product.template"
available_product_pricelist_item_ids = product_sudo.subscription_pricelist_rule_ids
first_pricelist_item_id = self.env['product.pricelist.item']
for product_pricelist_item_id in available_product_pricelist_item_ids:
if plan and product_pricelist_item_id.plan_id != plan:
continue
if product_pricelist_item_id._is_applies_to(product, product_uom_qty, date, is_product_template, plan, pricelist):
return product_pricelist_item_id
return first_pricelist_item_id

@api.model
def _get_first_suitable_rental_pricing(self, product, date, start_date, end_date, recurrence_id=None, pricelist=None):
""" Get a suitable pricing for given product and pricelist."""
product_sudo = product.sudo()
is_product_template = product_sudo._name == "product.template"
available_product_pricelist_item_ids = product_sudo.rental_pricelist_rule_ids
first_pricelist_item_id = self.env['product.pricelist.item']
for product_pricelist_item_id in available_product_pricelist_item_ids:
if not start_date or not end_date:
continue
duration_vals = self._compute_duration_vals(start_date, end_date)[product_pricelist_item_id.recurrence_id.unit]
if recurrence_id and product_pricelist_item_id.recurrence_id != recurrence_id:
continue
if product_pricelist_item_id._applies_to_rental(product, pricelist, is_product_template, duration_vals, date):
return product_pricelist_item_id
return first_pricelist_item_id

@api.model
def _compute_duration_vals(self, start_date, end_date):
"""Compute duration in various temporal units."""
duration = end_date - start_date
vals = {
'hour': (duration.days * 24 + duration.seconds / 3600),
'day': math.ceil((duration.days * 24 + duration.seconds / 3600) / 24),
'week': math.ceil((duration.days * 24 + duration.seconds / 3600) / (24 * 7)),
}
duration_diff = relativedelta(end_date, start_date)
months = 1 if any([duration_diff.days, duration_diff.hours, duration_diff.minutes]) else 0
months += duration_diff.months + duration_diff.years * 12
vals['month'] = months
vals['year'] = months / 12
return vals

@api.model
def _get_suitable_pricings(self, product, pricelist, start_date, end_date, date, first=False):
"""Get the suitable pricings for a product."""
is_product_template = product._name == "product.template"
available_pricings = self.env['product.pricelist.item']
if pricelist:
for pricing in product.rental_pricelist_rule_ids:
duration_vals = self._compute_duration_vals(start_date, end_date)[pricing.recurrence_id.unit]
if pricing.pricelist_id == pricelist \
and (is_product_template or pricing._applies_to_rental(product, pricelist, is_product_template, duration_vals, date)) \
and pricing.min_quantity <= duration_vals:
if first:
return pricing
available_pricings |= pricing
return available_pricings

def _is_applies_to(self, product, product_uom_qty, date, is_product_template, plan, pricelist):
return (
self.pricelist_id == pricelist
and (
is_product_template
or (
self.product_tmpl_id == product.product_tmpl_id
and (
self.applied_on == "1_product"
or product == self.product_id
)
)
)
and self.min_quantity <= product_uom_qty
and (
not self.date_start
or self.date_start <= date
)
and (
not self.date_end
or self.date_end >= date
)
)

def _compute_price_rental(self, duration, unit, product, quantity, date, start_date, end_date, uom=None, **kwargs):
"""Compute price based on the duration and unit."""
self.ensure_one()
if duration <= 0 or self.recurrence_id.duration <= 0:
return self._compute_price(product, quantity, uom, date, start_date=start_date, end_date=end_date)
if unit != self.recurrence_id.unit:
converted_duration = math.ceil(
(duration * PERIOD_RATIO[unit]) / (self.recurrence_id.duration * PERIOD_RATIO[self.recurrence_id.unit])
)
else:
converted_duration = math.ceil(duration / self.recurrence_id.duration)
return self._compute_price(product, quantity, uom, date, start_date=start_date, end_date=end_date, converted_duration=converted_duration)

def _applies_to_rental(self, product, pricelist, is_product_template, duration_vals, date):
""" Check whether current pricing applies to given product.
:param product.product product:
:return: true if current pricing is applicable for given product, else otherwise.
:rtype: bool
"""
return (
self.pricelist_id == pricelist
and (
is_product_template
or self.product_tmpl_id == product.product_tmpl_id
and (
self.applied_on == "1_product"
or product == self.product_id
)
)
and self.min_quantity <= duration_vals
and (
not self.date_start
or self.date_start <= date
)
and (
not self.date_end
or self.date_end >= date
)
)

def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=None, converted_duration=None, start_date=None, end_date=None, *kwargs):
"""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()

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':
if converted_duration:
price = convert(self.fixed_price * converted_duration)
else:
price = convert(self.fixed_price)
elif self.compute_price == 'percentage':
if plan_id:
base_price = self._compute_base_price(product, quantity, uom, date, currency, plan_id=plan_id)
elif converted_duration:
base_price = self._compute_base_price(product, quantity, uom, date, currency, recurrence_id=self.recurrence_id, start_date=start_date, end_date=end_date, converted_duration=converted_duration)
else:
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':
if plan_id:
base_price = self._compute_base_price(product, quantity, uom, date, currency, plan_id=plan_id)
elif converted_duration:
base_price = self._compute_base_price(product, quantity, uom, date, currency, recurrence_id=self.recurrence_id, start_date=start_date, end_date=end_date)
else:
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, plan_id=None, recurrence_id=None, converted_duration=None, start_date=None, end_date=None):
"""override method to compute base price for subscription and rental products."""
currency.ensure_one()
if plan_id and product.recurring_invoice:
rule_base = self.base or 'list_price'
if rule_base == 'pricelist' and self.base_pricelist_id:
price = self._get_first_suitable_recurring_pricing(
product, quantity, date, plan_id, self.base_pricelist_id
)._compute_price(product, quantity, uom, date)
src_currency = self.base_pricelist_id.currency_id
if src_currency != currency:
price = src_currency._convert(price, currency, self.env.company, date, round=False)
return price
elif recurrence_id and product.rent_ok:
duration_vals = self._compute_duration_vals(start_date, end_date)
converted_duration = self and duration_vals[self.recurrence_id.unit or 'day'] or 0
rule_base = self.base or 'list_price'
if rule_base == 'pricelist' and self.base_pricelist_id:
price = self._get_first_suitable_rental_pricing(
product, date, start_date, end_date, recurrence_id, self.base_pricelist_id
)._compute_price(product, quantity, uom, date)
src_currency = self.base_pricelist_id.currency_id
if src_currency != currency:
price = src_currency._convert(price, currency, self.env.company, date, round=False)
if converted_duration:
return price*converted_duration
return price
return super()._compute_base_price(product, quantity, uom, date, currency)
21 changes: 21 additions & 0 deletions pricelist_refactor/models/product_product.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from odoo import models


class ProductProduct(models.Model):
_inherit = 'product.product'

def _get_best_pricing_rule(self, quantity=None, date=None, **kwargs):
"""Return the best pricing rule for the given duration.

:return: least expensive pricing rule for given duration
:rtype: product.pricelist.item
"""
return self.product_tmpl_id._get_best_pricing_rule(quantity, date, product=self, **kwargs)

def _compute_rental_default(self, duration, unit):
"""Return the default rental prices for the given duration.

:return: default pricing for given duration
:rtype: int
"""
return self.product_tmpl_id._compute_rental_default(duration, unit)
Loading