From 78d4fa60027a76cfd126cb13d2043a0996ff1d1e Mon Sep 17 00:00:00 2001 From: vrgo-odoo Date: Mon, 17 Mar 2025 10:44:37 +0530 Subject: [PATCH 1/6] [IMP] pricelist_refactor: Refactor pricelist rules for rental and subscription In this commit Introduced discount and formula-based pricelist rules for subscription products. 1.Adding a subscription price rule opens a new form view, requiring a recurring plan. 2.Adding a rental price rule opens a new form view, requiring recurrence. 3.Sale order line price calculation updated to follow the new subscription rules. 4.Product template page updated for both sale products and subscription products --- pricelist_refactor/__init__.py | 1 + pricelist_refactor/__manifest__.py | 17 ++ pricelist_refactor/models/__init__.py | 5 + .../models/product_pricelist.py | 97 ++++++++++ .../models/product_pricelist_item.py | 170 ++++++++++++++++++ pricelist_refactor/models/product_product.py | 14 ++ pricelist_refactor/models/product_template.py | 77 ++++++++ pricelist_refactor/models/sale_order_line.py | 148 +++++++++++++++ .../views/product_pricelist_item_views.xml | 103 +++++++++++ .../views/product_pricelist_views.xml | 51 ++++++ .../views/product_template_views.xml | 63 +++++++ 11 files changed, 746 insertions(+) create mode 100644 pricelist_refactor/__init__.py create mode 100644 pricelist_refactor/__manifest__.py create mode 100644 pricelist_refactor/models/__init__.py create mode 100644 pricelist_refactor/models/product_pricelist.py create mode 100644 pricelist_refactor/models/product_pricelist_item.py create mode 100644 pricelist_refactor/models/product_product.py create mode 100644 pricelist_refactor/models/product_template.py create mode 100644 pricelist_refactor/models/sale_order_line.py create mode 100644 pricelist_refactor/views/product_pricelist_item_views.xml create mode 100644 pricelist_refactor/views/product_pricelist_views.xml create mode 100644 pricelist_refactor/views/product_template_views.xml diff --git a/pricelist_refactor/__init__.py b/pricelist_refactor/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/pricelist_refactor/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/pricelist_refactor/__manifest__.py b/pricelist_refactor/__manifest__.py new file mode 100644 index 00000000000..8fdae5d7f17 --- /dev/null +++ b/pricelist_refactor/__manifest__.py @@ -0,0 +1,17 @@ +{ + 'name': 'Pricelist Refactor', + 'version': '1.0', + 'category': 'sale_management', + 'depends': ['sale_subscription', 'sale_renting'], + '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', +} diff --git a/pricelist_refactor/models/__init__.py b/pricelist_refactor/models/__init__.py new file mode 100644 index 00000000000..458b21d511c --- /dev/null +++ b/pricelist_refactor/models/__init__.py @@ -0,0 +1,5 @@ +from . import product_pricelist +from . import product_pricelist_item +from . import sale_order_line +from . import product_template +from . import product_product diff --git a/pricelist_refactor/models/product_pricelist.py b/pricelist_refactor/models/product_pricelist.py new file mode 100644 index 00000000000..d66e0135bcd --- /dev/null +++ b/pricelist_refactor/models/product_pricelist.py @@ -0,0 +1,97 @@ +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="Price Items", + 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), + ] + ) + + rental_pricelist_ids = fields.One2many( + comodel_name='product.pricelist.item', + inverse_name='pricelist_id', + string="Rental Pricing", + 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, **kwargs + ): + 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: + # rental_products = products.filtered('rent_ok') + Pricing = self.env['product.pricelist.item'] + # for product in rental_products: + if start_date and end_date: + pricing = product._get_best_pricing_rule( + quantity, date, uom=kwargs.get("uom"), start_date=start_date, end_date=end_date, pricelist=self, currency=currency + ) + duration_vals = Pricing._compute_duration_vals(start_date, end_date) + duration = pricing and duration_vals[pricing.recurrence_id.unit or 'day'] or 0 + else: + pricing = Pricing._get_first_suitable_pricing(product, self) + duration = pricing.recurrence_id.duration + + if pricing: + price = pricing._compute_price_rental(duration, pricing.recurrence_id.unit, product, quantity, date, start_date, end_date, uom=kwargs.get("uom")) + elif product._name == 'product.product': + price = product.lst_price + else: + price = product.list_price + results[product.id] = pricing.currency_id._convert( + price, currency, self.env.company, date + ), False + elif product.recurring_invoice: + # subscription_products = products.filtered('recurring_invoice') + # for product in subscription_products: + results[product.id] = self.env['product.pricelist.item']._get_first_suitable_recurring_pricing(product, plan=plan_id, pricelist=self)._compute_price(product, quantity, kwargs.get('uom'), date, plan_id=plan_id), self.env['product.pricelist.item']._get_first_suitable_recurring_pricing(product, plan=plan_id, pricelist=self).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 pricing should be used.""" + return bool(start_date and end_date) diff --git a/pricelist_refactor/models/product_pricelist_item.py b/pricelist_refactor/models/product_pricelist_item.py new file mode 100644 index 00000000000..2004a4a063b --- /dev/null +++ b/pricelist_refactor/models/product_pricelist_item.py @@ -0,0 +1,170 @@ +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("Active", default=True) + plan_id = fields.Many2one('sale.subscription.plan', string="Recurring Plan") + recurrence_id = fields.Many2one('sale.temporal.recurrence', string="Renting Period") + + @api.model + def _get_first_suitable_recurring_pricing(self, product, plan=None, pricelist=None): + """ Get a suitable pricing for given product and pricelist. + Note: model method + """ + product_sudo = product.sudo() + is_product_template = product_sudo._name == "product.template" + available_pricings = product_sudo.subscription_pricelist_rule_ids + first_pricing = self.env['product.pricelist.item'] + for pricing in available_pricings: + if plan and pricing.plan_id != plan: + continue + if pricing.pricelist_id == pricelist and (is_product_template or pricing._applies_to(product_sudo)): + return pricing + if not first_pricing and pricing.pricelist_id and (is_product_template or pricing._applies_to(product_sudo)): + # If price list and current pricing is not part of it, + # We store the first one to return if not pricing matching the price list is found. + first_pricing = pricing + return first_pricing + + def _compute_price_rental(self, duration, unit, product, quantity, date, start_date, end_date, **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, kwargs.get("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, kwargs.get("uom"), date, start_date=start_date, end_date=end_date) * converted_duration + + @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=None, 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: + if pricing.pricelist_id == pricelist\ + and (is_product_template or pricing._applies_to(product)): + if first: + return pricing + available_pricings |= pricing + + for pricing in product.rental_pricelist_rule_ids: + if not pricing.pricelist_id and (is_product_template or pricing._applies_to(product)): + if first: + return pricing + available_pricings |= pricing + + return available_pricings + + def _applies_to(self, product): + """ Check whether current pricing applies to given product. + :param product.product product: + :return: true if current pricing is applicable for given product, else otherwise. + """ + self.ensure_one() + return ( + self.product_tmpl_id == product.product_tmpl_id + and ( + self.applied_on == "1_product" + or product == self.product_id)) + + def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=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() + 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, plan_id=plan_id) if plan_id \ + else 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, plan_id=plan_id) if plan_id \ + else 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): + 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, 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 + return super()._compute_base_price(product, quantity, uom, date, currency) diff --git a/pricelist_refactor/models/product_product.py b/pricelist_refactor/models/product_product.py new file mode 100644 index 00000000000..e371aea4809 --- /dev/null +++ b/pricelist_refactor/models/product_product.py @@ -0,0 +1,14 @@ +from odoo import models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + + def _get_best_pricing_rule(self, quantity, date, **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) \ No newline at end of file diff --git a/pricelist_refactor/models/product_template.py b/pricelist_refactor/models/product_template.py new file mode 100644 index 00000000000..35830b7230b --- /dev/null +++ b/pricelist_refactor/models/product_template.py @@ -0,0 +1,77 @@ +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + pricelist_rule_ids = fields.One2many( + string="Pricelist Rules", + comodel_name='product.pricelist.item', + inverse_name='product_tmpl_id', + domain=lambda self: [ + '|', + ('product_tmpl_id', 'in', self.ids), + ('product_id', 'in', self.product_variant_ids.ids), + ('plan_id', '=', None), + ('recurrence_id', '=', None) + ] + ) + subscription_pricelist_rule_ids = fields.One2many( + string="Pricelist Rules", + comodel_name='product.pricelist.item', + inverse_name='product_tmpl_id', + domain=lambda self: [ + '|', + ('product_tmpl_id', 'in', self.ids), + ('product_id', 'in', self.product_variant_ids.ids), + ('plan_id', '!=', None) + ] + ) + rental_pricelist_rule_ids = fields.One2many( + string="Pricelist Rules", + comodel_name='product.pricelist.item', + inverse_name='product_tmpl_id', + domain=lambda self: [ + '|', + ('product_tmpl_id', 'in', self.ids), + ('product_id', 'in', self.product_variant_ids.ids), + ('recurrence_id', '!=', None) + ] + ) + + default_rent_unit = fields.Many2one(comodel_name='sale.temporal.unit', string="Default Sale Unit") + default_rent_price = fields.Monetary(string="Price") + + def _get_best_pricing_rule(self, quantity, date, product=False, start_date=False, end_date=False, **kwargs): + """ Return the best pricing rule for the given duration. + + :param ProductProduct product: a product recordset (containing at most one record) + :param datetime start_date: start date of leasing period + :param datetime end_date: end date of leasing period + :return: least expensive pricing rule for given duration + """ + self.ensure_one() + best_pricing_rule = self.env['product.pricelist.item'] + if not self.product_pricing_ids or not (start_date and end_date): + return best_pricing_rule + pricelist = kwargs.get('pricelist', self.env['product.pricelist']) + currency = kwargs.get('currency', self.currency_id) + company = kwargs.get('company', self.env.company) + duration_dict = self.env['product.pricelist.item']._compute_duration_vals(start_date, end_date) + min_price = float("inf") # positive infinity + available_pricings = self.env['product.pricelist.item']._get_suitable_pricings( + product or self, pricelist=pricelist + ) + for pricing in available_pricings: + unit = pricing.recurrence_id.unit + price = pricing._compute_price_rental(duration_dict[unit], unit, product, quantity, date, start_date, end_date, uom=kwargs.get("uom"),base_price=kwargs.get("base_price")) + if pricing.currency_id != currency: + price = pricing.currency_id._convert( + from_amount=price, + to_currency=currency, + company=company, + date=fields.Date.today(), + ) + if price < min_price: + min_price, best_pricing_rule = price, pricing + return best_pricing_rule diff --git a/pricelist_refactor/models/sale_order_line.py b/pricelist_refactor/models/sale_order_line.py new file mode 100644 index 00000000000..1c6d3ab95d6 --- /dev/null +++ b/pricelist_refactor/models/sale_order_line.py @@ -0,0 +1,148 @@ +from odoo import api, Command, fields, models +from collections import defaultdict + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + def _get_pricelist_price(self): + self.ensure_one() + self.product_id.ensure_one() + if self.product_template_id.rent_ok: + self.order_id._rental_set_dates() + return self.order_id.pricelist_id._get_product_price( + self.product_id.with_context(**self._get_product_price_context()), + self.product_uom_qty or 1.0, + order_id=self.order_id, + currency=self.currency_id, + uom=self.product_uom, + date=self.order_id.date_order or fields.Date.today(), + start_date=self.start_date, + end_date=self.return_date, + ) + elif self.product_template_id.recurring_invoice: + if self.order_id.plan_id and self.order_id.pricelist_id: + return self.env['product.pricelist.item']._get_first_suitable_recurring_pricing( + self.product_id, + self.order_id.plan_id, + self.pricelist_id + )._compute_price( + product=self.product_id.with_context(**self._get_product_price_context()), + quantity=self.product_uom_qty or 1.0, + uom=self.product_uom, + date=self.order_id.date_order, + plan_id=self.order_id.plan_id + ) + return self.pricelist_item_id._compute_price( + product=self.product_id.with_context(**self._get_product_price_context()), + quantity=self.product_uom_qty or 1.0, + uom=self.product_uom, + date=self.order_id.date_order, + currency=self.currency_id, + ) + else: + return self.pricelist_item_id._compute_price( + product=self.product_id.with_context(**self._get_product_price_context()), + quantity=self.product_uom_qty or 1.0, + uom=self.product_uom, + date=self.order_id.date_order, + currency=self.currency_id, + ) + + @api.depends('order_id.subscription_state', 'order_id.start_date') + def _compute_discount(self): + """ For upsells : this method compute the prorata ratio for upselling when the current and possibly future + period have already been invoiced. + The algorithm work backward by trying to remove one period at a time from the end to have a number of + complete period before computing the prorata for the current period. + For the current period, we use the remaining number of days / by the number of day in the current period. + """ + today = fields.Date.today() + other_lines = self.env['sale.order.line'] + line_per_so = defaultdict(lambda: self.env['sale.order.line']) + for line in self: + if not line.recurring_invoice: + other_lines += line # normal sale line are handled by super + else: + line_per_so[line.order_id._origin.id] += line + + for so_id, lines in line_per_so.items(): + order_id = self.env['sale.order'].browse(so_id) + parent_id = order_id.subscription_id + if not parent_id: + other_lines += lines + continue + if not parent_id.next_invoice_date or order_id.subscription_state != '7_upsell': + # We don't apply discount + continue + start_date = max(order_id.start_date or today, order_id.first_contract_date or today) + end_date = parent_id.next_invoice_date + if start_date >= end_date: + ratio = 0 + else: + recurrence = parent_id.plan_id.billing_period + complete_rec = 0 + while end_date - recurrence >= start_date: + complete_rec += 1 + end_date -= recurrence + ratio = (end_date - start_date).days / ((start_date + recurrence) - start_date).days + complete_rec + # If the parent line had a discount, we reapply it to keep the same conditions. + # E.G. base price is 200€, parent line has a 10% discount and upsell has a 25% discount. + # We want to apply a final price equal to 200 * 0.75 (prorata) * 0.9 (discount) = 135 or 200*0,675 + # We need 32.5 in the discount + add_comment = False + line_to_discount, discount_comment = lines._get_renew_discount_info() + for line in line_to_discount: + if line.parent_line_id and line.parent_line_id.discount: + line.discount = (1 - ratio * (1 - line.parent_line_id.discount / 100)) * 100 + else: + line.discount = (1 - ratio) * 100 + # Add prorata reason for discount if necessary + if ratio != 1 and "(*)" not in line.name: + line.name += "(*)" + add_comment = True + if add_comment and not any((l.display_type == 'line_note' and '(*)' in l.name) for l in order_id.order_line): + order_id.order_line = [Command.create({ + 'display_type': 'line_note', + 'sequence': 999, + 'name': discount_comment, + 'product_uom_qty': 0, + })] + discount_enabled = self.env['product.pricelist.item']._is_discount_feature_enabled() + for line in other_lines: + if not line.product_id or line.display_type: + line.discount = 0.0 + + if not (line.order_id.pricelist_id and discount_enabled): + continue + + line.discount = 0.0 + if not (line.pricelist_item_id and line.pricelist_item_id._show_discount()): + # No pricelist rule was found for the product + # therefore, the pricelist didn't apply any discount/change + # to the existing sales price. + continue + + line = line.with_company(line.company_id) + pricelist_price = line._get_pricelist_price() + base_price = line._get_pricelist_price_before_discount() + + if base_price != 0: # Avoid division by zero + discount = (base_price - pricelist_price) / base_price * 100 + if (discount > 0 and base_price > 0) or (discount < 0 and base_price < 0): + # only show negative discounts if price is negative + # otherwise it's a surcharge which shouldn't be shown to the customer + line.discount = discount + + def _compute_pricelist_item_id(self): + for line in self: + if not line.product_id or line.display_type or not line.order_id.pricelist_id: + line.pricelist_item_id = False + else: + line.pricelist_item_id = line.order_id.pricelist_id._get_product_rule( + line.product_id, + quantity=line.product_uom_qty or 1.0, + uom=line.product_uom, + date=line.order_id.date_order, + plan_id=line.order_id.plan_id + ) diff --git a/pricelist_refactor/views/product_pricelist_item_views.xml b/pricelist_refactor/views/product_pricelist_item_views.xml new file mode 100644 index 00000000000..b5ba8504822 --- /dev/null +++ b/pricelist_refactor/views/product_pricelist_item_views.xml @@ -0,0 +1,103 @@ + + + + + product.pricelist.item.product.template.form.inherit + product.pricelist.item + + primary + + + 1 + + + 1 + 1 + + + + + + + + + product.pricelist.item.product.product.form.inherit + product.pricelist.item + + primary + + + 1 + 1 + + + + + + sale.subscription.product.pricelist.item.form.subscription + product.pricelist.item + primary + + + + [('recurring_invoice', '=', True)] + + + + + + + + + rental.product.pricelist.item.form.subscription + product.pricelist.item + primary + + + + [('rent_ok', '=', True)] + + + + + + + + + product.pricelist.item.sale.subscription.form.inherit + product.pricelist.item + + primary + + + + + + + + + + product.pricelist.item.search.remove_active_filter + product.pricelist.item + + + + + + + + sale.subscription.product.pricelist.item.form + product.pricelist.item + + + + [('recurring_invoice', '=', False)] + + + + + diff --git a/pricelist_refactor/views/product_pricelist_views.xml b/pricelist_refactor/views/product_pricelist_views.xml new file mode 100644 index 00000000000..9a0af20131a --- /dev/null +++ b/pricelist_refactor/views/product_pricelist_views.xml @@ -0,0 +1,51 @@ + + + + sale.subscription.product.pricelist.form.inherit + product.pricelist + + + + + + + + + + + + + + + + + + + + + Sales prices + + + + + + + + + + + + + + + + + + + + + diff --git a/pricelist_refactor/views/product_template_views.xml b/pricelist_refactor/views/product_template_views.xml new file mode 100644 index 00000000000..55d8add1af2 --- /dev/null +++ b/pricelist_refactor/views/product_template_views.xml @@ -0,0 +1,63 @@ + + + + sale.subscription.product.template.form.inherit + product.template + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From adbf6c22a52b6809870dab0386bb8ae60c409910 Mon Sep 17 00:00:00 2001 From: vrgo-odoo Date: Mon, 17 Mar 2025 19:04:55 +0530 Subject: [PATCH 2/6] [IMP] pricelist_refactor: pricelist for rental is added In this commit 1.pricelist calulation for rental product is added. 2.new page pricing is added in product page. --- pricelist_refactor/models/__init__.py | 1 + .../models/product_pricelist.py | 14 ++-- .../models/product_pricelist_item.py | 50 +++++++++-- pricelist_refactor/models/product_product.py | 11 ++- pricelist_refactor/models/product_template.py | 27 ++++-- .../models/res_config_settings.py | 19 +++++ pricelist_refactor/models/sale_order_line.py | 44 +++++----- .../views/product_pricelist_item_views.xml | 14 +++- .../views/product_template_views.xml | 82 +++++++++++++++++-- 9 files changed, 210 insertions(+), 52 deletions(-) create mode 100644 pricelist_refactor/models/res_config_settings.py diff --git a/pricelist_refactor/models/__init__.py b/pricelist_refactor/models/__init__.py index 458b21d511c..8b55cee6089 100644 --- a/pricelist_refactor/models/__init__.py +++ b/pricelist_refactor/models/__init__.py @@ -3,3 +3,4 @@ from . import sale_order_line from . import product_template from . import product_product +from . import res_config_settings diff --git a/pricelist_refactor/models/product_pricelist.py b/pricelist_refactor/models/product_pricelist.py index d66e0135bcd..c7b94f7ae7d 100644 --- a/pricelist_refactor/models/product_pricelist.py +++ b/pricelist_refactor/models/product_pricelist.py @@ -7,7 +7,7 @@ class ProductPricelist(models.Model): item_ids = fields.One2many( comodel_name='product.pricelist.item', inverse_name='pricelist_id', - string="Price Items", + string="Pricing Rules", domain=[ '&', '|', ('product_tmpl_id', '=', None), ('product_tmpl_id.active', '=', True), @@ -32,7 +32,7 @@ class ProductPricelist(models.Model): rental_pricelist_ids = fields.One2many( comodel_name='product.pricelist.item', inverse_name='pricelist_id', - string="Rental Pricing", + string="Rental Pricing Rules", domain=[ '&', '|', ('product_tmpl_id', '=', None), ('product_tmpl_id.active', '=', True), @@ -45,6 +45,7 @@ class ProductPricelist(models.Model): def _compute_price_rule( self, products, quantity, currency=None, date=False, start_date=None, end_date=None, plan_id=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 @@ -58,9 +59,7 @@ def _compute_price_rule( for product in products: if self._enable_rental_price(start_date, end_date) and product.rent_ok: - # rental_products = products.filtered('rent_ok') Pricing = self.env['product.pricelist.item'] - # for product in rental_products: if start_date and end_date: pricing = product._get_best_pricing_rule( quantity, date, uom=kwargs.get("uom"), start_date=start_date, end_date=end_date, pricelist=self, currency=currency @@ -70,9 +69,12 @@ def _compute_price_rule( else: pricing = Pricing._get_first_suitable_pricing(product, self) duration = pricing.recurrence_id.duration - if pricing: price = pricing._compute_price_rental(duration, pricing.recurrence_id.unit, product, quantity, date, start_date, end_date, uom=kwargs.get("uom")) + elif product.default_rent_unit: + duration_vals = Pricing._compute_duration_vals(start_date, end_date) + duration = product.default_rent_unit and duration_vals[pricing.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: @@ -81,8 +83,6 @@ def _compute_price_rule( price, currency, self.env.company, date ), False elif product.recurring_invoice: - # subscription_products = products.filtered('recurring_invoice') - # for product in subscription_products: results[product.id] = self.env['product.pricelist.item']._get_first_suitable_recurring_pricing(product, plan=plan_id, pricelist=self)._compute_price(product, quantity, kwargs.get('uom'), date, plan_id=plan_id), self.env['product.pricelist.item']._get_first_suitable_recurring_pricing(product, plan=plan_id, pricelist=self).id price_computed_products = self.env[products._name].browse(results.keys()) return { diff --git a/pricelist_refactor/models/product_pricelist_item.py b/pricelist_refactor/models/product_pricelist_item.py index 2004a4a063b..1d043b931ea 100644 --- a/pricelist_refactor/models/product_pricelist_item.py +++ b/pricelist_refactor/models/product_pricelist_item.py @@ -20,9 +20,7 @@ class ProductPricelistItem(models.Model): @api.model def _get_first_suitable_recurring_pricing(self, product, plan=None, pricelist=None): - """ Get a suitable pricing for given product and pricelist. - Note: model method - """ + """ Get a suitable pricing for given product and pricelist.""" product_sudo = product.sudo() is_product_template = product_sudo._name == "product.template" available_pricings = product_sudo.subscription_pricelist_rule_ids @@ -38,6 +36,24 @@ def _get_first_suitable_recurring_pricing(self, product, plan=None, pricelist=No first_pricing = pricing return first_pricing + @api.model + def _get_first_suitable_rental_pricing(self, product, 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_pricings = product_sudo.rental_pricelist_rule_ids + first_pricing = self.env['product.pricelist.item'] + for pricing in available_pricings: + if recurrence_id and pricing.recurrence_id != recurrence_id: + continue + if pricing.pricelist_id == pricelist and (is_product_template or pricing._applies_to(product_sudo)): + return pricing + if not first_pricing and pricing.pricelist_id and (is_product_template or pricing._applies_to(product_sudo)): + # If price list and current pricing is not part of it, + # We store the first one to return if not pricing matching the price list is found. + first_pricing = pricing + return first_pricing + def _compute_price_rental(self, duration, unit, product, quantity, date, start_date, end_date, **kwargs): """Compute price based on the duration and unit.""" self.ensure_one() @@ -92,6 +108,7 @@ def _applies_to(self, product): """ 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 """ self.ensure_one() return ( @@ -132,12 +149,21 @@ def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=No 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, plan_id=plan_id) if plan_id \ - else self._compute_base_price(product, quantity, uom, date, currency) + if plan_id: + base_price = self._compute_base_price(product, quantity, uom, date, currency, plan_id=plan_id) + elif start_date and end_date: + base_price = self._compute_base_price(product, quantity, uom, date, currency, recurrence_id=self.recurrence_id) + 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': - base_price = self._compute_base_price(product, quantity, uom, date, currency, plan_id=plan_id) if plan_id \ - else self._compute_base_price(product, quantity, uom, date, currency) + if plan_id: + base_price = self._compute_base_price(product, quantity, uom, date, currency, plan_id=plan_id) + elif start_date and end_date: + base_price = self._compute_base_price(product, quantity, uom, date, currency, recurrence_id=self.recurrence_id) + 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 @@ -158,6 +184,7 @@ def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=No return price def _compute_base_price(self, product, quantity, uom, date, currency, plan_id=None, recurrence_id=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' @@ -167,4 +194,13 @@ def _compute_base_price(self, product, quantity, uom, date, currency, plan_id=No 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: + rule_base = self.base or 'list_price' + if rule_base == 'pricelist' and self.base_pricelist_id: + breakpoint() + price = self._get_first_suitable_rental_pricing(product, 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) + return price return super()._compute_base_price(product, quantity, uom, date, currency) diff --git a/pricelist_refactor/models/product_product.py b/pricelist_refactor/models/product_product.py index e371aea4809..835b82bde9c 100644 --- a/pricelist_refactor/models/product_product.py +++ b/pricelist_refactor/models/product_product.py @@ -4,11 +4,18 @@ class ProductProduct(models.Model): _inherit = "product.product" - def _get_best_pricing_rule(self, quantity, date, **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) \ No newline at end of file + 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) diff --git a/pricelist_refactor/models/product_template.py b/pricelist_refactor/models/product_template.py index 35830b7230b..b4fad8984c5 100644 --- a/pricelist_refactor/models/product_template.py +++ b/pricelist_refactor/models/product_template.py @@ -1,5 +1,14 @@ +import math from odoo import fields, models +PERIOD_RATIO = { + 'hour': 1, + 'day': 24, + 'week': 24 * 7, + 'month': 24 * 31, + 'year': 24 * 31 * 12, +} + class ProductTemplate(models.Model): _inherit = 'product.template' @@ -17,30 +26,29 @@ class ProductTemplate(models.Model): ] ) subscription_pricelist_rule_ids = fields.One2many( - string="Pricelist Rules", + string="Subscription Pricelist Rules", comodel_name='product.pricelist.item', inverse_name='product_tmpl_id', domain=lambda self: [ '|', ('product_tmpl_id', 'in', self.ids), ('product_id', 'in', self.product_variant_ids.ids), - ('plan_id', '!=', None) + ('plan_id', '!=', False) ] ) rental_pricelist_rule_ids = fields.One2many( - string="Pricelist Rules", + string="Rental Pricelist Rules", comodel_name='product.pricelist.item', inverse_name='product_tmpl_id', domain=lambda self: [ '|', ('product_tmpl_id', 'in', self.ids), ('product_id', 'in', self.product_variant_ids.ids), - ('recurrence_id', '!=', None) + ('recurrence_id', '!=', False) ] ) - default_rent_unit = fields.Many2one(comodel_name='sale.temporal.unit', string="Default Sale Unit") - default_rent_price = fields.Monetary(string="Price") + default_rent_unit = fields.Many2one(comodel_name='sale.temporal.recurrence', string="Default Sale Unit") def _get_best_pricing_rule(self, quantity, date, product=False, start_date=False, end_date=False, **kwargs): """ Return the best pricing rule for the given duration. @@ -75,3 +83,10 @@ def _get_best_pricing_rule(self, quantity, date, product=False, start_date=False if price < min_price: min_price, best_pricing_rule = price, pricing return best_pricing_rule + + def _compute_rental_default(self, duration, unit): + """computing default rental price when no rule is avilable for rental product""" + if duration <= 0: + return self.list_price + converted_duration = math.ceil(duration / self.default_rent_unit.duration) + return self.list_price * converted_duration diff --git a/pricelist_refactor/models/res_config_settings.py b/pricelist_refactor/models/res_config_settings.py new file mode 100644 index 00000000000..7b9abc67ac2 --- /dev/null +++ b/pricelist_refactor/models/res_config_settings.py @@ -0,0 +1,19 @@ +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + module_sale_subscription = fields.Boolean("Sale Subscription") + group_product_pricelist = fields.Boolean("Pricelists", default=True) + + @api.constrains('group_product_pricelist') + def _check_pricelist_requirement(self): + """Prevent disabling pricelists if Sale Subscription is installed""" + for config in self: + if not config.group_product_pricelist and self.env['ir.module.module'].search_count([ + ('name', '=', 'sale_subscription'), + ('state', '=', 'installed') + ]): + raise UserError(_("Pricelists are required for Sale Subscription. You cannot disable them.")) diff --git a/pricelist_refactor/models/sale_order_line.py b/pricelist_refactor/models/sale_order_line.py index 1c6d3ab95d6..b4856f2b28d 100644 --- a/pricelist_refactor/models/sale_order_line.py +++ b/pricelist_refactor/models/sale_order_line.py @@ -6,6 +6,11 @@ class SaleOrderLine(models.Model): _inherit = 'sale.order.line' def _get_pricelist_price(self): + """Compute the price given by the pricelist for the given line information. + + :return: the product sales price in the order currency (without taxes) + :rtype: float + """ self.ensure_one() self.product_id.ensure_one() if self.product_template_id.rent_ok: @@ -22,34 +27,29 @@ def _get_pricelist_price(self): ) elif self.product_template_id.recurring_invoice: if self.order_id.plan_id and self.order_id.pricelist_id: - return self.env['product.pricelist.item']._get_first_suitable_recurring_pricing( - self.product_id, - self.order_id.plan_id, - self.pricelist_id - )._compute_price( - product=self.product_id.with_context(**self._get_product_price_context()), - quantity=self.product_uom_qty or 1.0, - uom=self.product_uom, - date=self.order_id.date_order, - plan_id=self.order_id.plan_id + pricing_item = self.env['product.pricelist.item']._get_first_suitable_recurring_pricing( + self.product_id, self.order_id.plan_id, self.pricelist_id ) - return self.pricelist_item_id._compute_price( - product=self.product_id.with_context(**self._get_product_price_context()), - quantity=self.product_uom_qty or 1.0, - uom=self.product_uom, - date=self.order_id.date_order, - currency=self.currency_id, + else: + pricing_item = self.pricelist_item_id + return pricing_item._compute_price( + self.product_id.with_context(**self._get_product_price_context()), + self.product_uom_qty or 1.0, + self.product_uom, + self.order_id.date_order, + self.currency_id, + self.order_id.plan_id ) else: return self.pricelist_item_id._compute_price( - product=self.product_id.with_context(**self._get_product_price_context()), - quantity=self.product_uom_qty or 1.0, - uom=self.product_uom, - date=self.order_id.date_order, - currency=self.currency_id, + self.product_id.with_context(**self._get_product_price_context()), + self.product_uom_qty or 1.0, + self.product_uom, + self.order_id.date_order, + self.currency_id, ) - @api.depends('order_id.subscription_state', 'order_id.start_date') + @api.depends('order_id.subscription_state', 'order_id.start_date', 'order_id.rental_start_date', 'order_id.rental_return_date') def _compute_discount(self): """ For upsells : this method compute the prorata ratio for upselling when the current and possibly future period have already been invoiced. diff --git a/pricelist_refactor/views/product_pricelist_item_views.xml b/pricelist_refactor/views/product_pricelist_item_views.xml index b5ba8504822..06d1e79bb95 100644 --- a/pricelist_refactor/views/product_pricelist_item_views.xml +++ b/pricelist_refactor/views/product_pricelist_item_views.xml @@ -75,6 +75,18 @@ + + product.pricelist.item.sale.rental.form.inherit + product.pricelist.item + + primary + + + + + + + product.pricelist.item.search.remove_active_filter @@ -95,7 +107,7 @@ - [('recurring_invoice', '=', False)] + [('recurring_invoice', '=', False), ('rent_ok', '=', False)] diff --git a/pricelist_refactor/views/product_template_views.xml b/pricelist_refactor/views/product_template_views.xml index 55d8add1af2..80a2274e485 100644 --- a/pricelist_refactor/views/product_template_views.xml +++ b/pricelist_refactor/views/product_template_views.xml @@ -3,23 +3,44 @@ sale.subscription.product.template.form.inherit product.template - + - + + + + + per + + + - + + - @@ -32,7 +53,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + per + + + + + + - + + + + + + From 246e63377d51713dffbf85627645ebb3e2d276aa Mon Sep 17 00:00:00 2001 From: vrgo-odoo Date: Tue, 18 Mar 2025 18:33:19 +0530 Subject: [PATCH 3/6] [IMP] pricelist_refactor: add logic to update rental prices In this commit 1.Implemented logic to update rental prices based on the renting period and applicable price list. 2.Added discount-related computations to ensure correct price adjustments. 3.Applied code formatting improvements. --- pricelist_refactor/__manifest__.py | 2 +- .../models/product_pricelist.py | 29 +++++----- .../models/product_pricelist_item.py | 31 +++++----- pricelist_refactor/models/product_product.py | 4 +- pricelist_refactor/models/product_template.py | 16 ++--- .../models/res_config_settings.py | 4 +- pricelist_refactor/models/sale_order_line.py | 58 ++++++++++++++----- .../views/product_pricelist_views.xml | 2 + .../views/product_template_views.xml | 2 +- 9 files changed, 89 insertions(+), 59 deletions(-) diff --git a/pricelist_refactor/__manifest__.py b/pricelist_refactor/__manifest__.py index 8fdae5d7f17..a02f60674d7 100644 --- a/pricelist_refactor/__manifest__.py +++ b/pricelist_refactor/__manifest__.py @@ -2,7 +2,7 @@ 'name': 'Pricelist Refactor', 'version': '1.0', 'category': 'sale_management', - 'depends': ['sale_subscription', 'sale_renting'], + 'depends': ['sale_subscription', 'sale_renting', 'product'], 'description': """ Refactors the pricelist functionality for rental and subscription products, introducing discount and formula-based pricing rules. diff --git a/pricelist_refactor/models/product_pricelist.py b/pricelist_refactor/models/product_pricelist.py index c7b94f7ae7d..6e3e234e9b0 100644 --- a/pricelist_refactor/models/product_pricelist.py +++ b/pricelist_refactor/models/product_pricelist.py @@ -41,13 +41,11 @@ class ProductPricelist(models.Model): ] ) - def _compute_price_rule( - self, products, quantity, currency=None, date=False, start_date=None, end_date=None, plan_id=None, **kwargs + 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: @@ -57,33 +55,32 @@ def _compute_price_rule( 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: - pricing = product._get_best_pricing_rule( - quantity, date, uom=kwargs.get("uom"), start_date=start_date, end_date=end_date, pricelist=self, currency=currency + 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 = pricing and duration_vals[pricing.recurrence_id.unit or 'day'] or 0 + duration = pricelist_id and duration_vals[pricelist_id.recurrence_id.unit or 'day'] or 0 else: - pricing = Pricing._get_first_suitable_pricing(product, self) - duration = pricing.recurrence_id.duration - if pricing: - price = pricing._compute_price_rental(duration, pricing.recurrence_id.unit, product, quantity, date, start_date, end_date, uom=kwargs.get("uom")) + 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[pricing.recurrence_id.unit or 'day'] or 0 + 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] = pricing.currency_id._convert( + results[product.id] = pricelist_id.currency_id._convert( price, currency, self.env.company, date - ), False + ), pricelist_id.id elif product.recurring_invoice: - results[product.id] = self.env['product.pricelist.item']._get_first_suitable_recurring_pricing(product, plan=plan_id, pricelist=self)._compute_price(product, quantity, kwargs.get('uom'), date, plan_id=plan_id), self.env['product.pricelist.item']._get_first_suitable_recurring_pricing(product, plan=plan_id, pricelist=self).id + results[product.id] = self.env['product.pricelist.item']._get_first_suitable_recurring_pricing(product, plan=plan_id, pricelist=self)._compute_price(product, quantity, uom, date, plan_id=plan_id), self.env['product.pricelist.item']._get_first_suitable_recurring_pricing(product, plan=plan_id, pricelist=self).id price_computed_products = self.env[products._name].browse(results.keys()) return { **results, @@ -93,5 +90,5 @@ def _compute_price_rule( } def _enable_rental_price(self, start_date, end_date): - """Determine if rental pricing should be used.""" + """Determine if rental pricelist_id should be used.""" return bool(start_date and end_date) diff --git a/pricelist_refactor/models/product_pricelist_item.py b/pricelist_refactor/models/product_pricelist_item.py index 1d043b931ea..434b30ac146 100644 --- a/pricelist_refactor/models/product_pricelist_item.py +++ b/pricelist_refactor/models/product_pricelist_item.py @@ -14,9 +14,9 @@ class ProductPricelistItem(models.Model): _inherit = 'product.pricelist.item' - active = fields.Boolean("Active", default=True) - plan_id = fields.Many2one('sale.subscription.plan', string="Recurring Plan") - recurrence_id = fields.Many2one('sale.temporal.recurrence', string="Renting Period") + 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, plan=None, pricelist=None): @@ -54,18 +54,18 @@ def _get_first_suitable_rental_pricing(self, product, recurrence_id=None, pricel first_pricing = pricing return first_pricing - def _compute_price_rental(self, duration, unit, product, quantity, date, start_date, end_date, **kwargs): + 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, kwargs.get("uom"), date, start_date=start_date, end_date=end_date) + return self._compute_price(product, quantity, uom, 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, kwargs.get("uom"), date, start_date=start_date, end_date=end_date) * converted_duration + return self._compute_price(product, quantity, uom, date, converted_duration=converted_duration) @api.model def _compute_duration_vals(self, start_date, end_date): @@ -117,7 +117,7 @@ def _applies_to(self, product): self.applied_on == "1_product" or product == self.product_id)) - def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=None, start_date=None, end_date=None, *kwargs): + def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=None, converted_duration=None, *kwargs): """Compute the unit price of a product in the context of a pricelist application. Note: self and self.ensure_one() @@ -134,7 +134,6 @@ def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=No """ 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() @@ -147,19 +146,22 @@ def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=No else: convert = lambda p: p if self.compute_price == 'fixed': - price = convert(self.fixed_price) + 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 start_date and end_date: - base_price = self._compute_base_price(product, quantity, uom, date, currency, recurrence_id=self.recurrence_id) + elif converted_duration: + base_price = self._compute_base_price(product, quantity, uom, date, currency, recurrence_id=self.recurrence_id, 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 start_date and end_date: + elif converted_duration: base_price = self._compute_base_price(product, quantity, uom, date, currency, recurrence_id=self.recurrence_id) else: base_price = self._compute_base_price(product, quantity, uom, date, currency) @@ -183,7 +185,7 @@ def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=No return price - def _compute_base_price(self, product, quantity, uom, date, currency, plan_id=None, recurrence_id=None): + def _compute_base_price(self, product, quantity, uom, date, currency, plan_id=None, recurrence_id=None, converted_duration=None): """override method to compute base price for subscription and rental products.""" currency.ensure_one() if plan_id and product.recurring_invoice: @@ -197,10 +199,11 @@ def _compute_base_price(self, product, quantity, uom, date, currency, plan_id=No elif recurrence_id and product.rent_ok: rule_base = self.base or 'list_price' if rule_base == 'pricelist' and self.base_pricelist_id: - breakpoint() price = self._get_first_suitable_rental_pricing(product, 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) diff --git a/pricelist_refactor/models/product_product.py b/pricelist_refactor/models/product_product.py index 835b82bde9c..07e0f17a6ec 100644 --- a/pricelist_refactor/models/product_product.py +++ b/pricelist_refactor/models/product_product.py @@ -2,9 +2,9 @@ class ProductProduct(models.Model): - _inherit = "product.product" + _inherit = 'product.product' - def _get_best_pricing_rule(self, quantity, date, **kwargs): + 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 diff --git a/pricelist_refactor/models/product_template.py b/pricelist_refactor/models/product_template.py index b4fad8984c5..db867ac0d24 100644 --- a/pricelist_refactor/models/product_template.py +++ b/pricelist_refactor/models/product_template.py @@ -50,7 +50,7 @@ class ProductTemplate(models.Model): default_rent_unit = fields.Many2one(comodel_name='sale.temporal.recurrence', string="Default Sale Unit") - def _get_best_pricing_rule(self, quantity, date, product=False, start_date=False, end_date=False, **kwargs): + def _get_best_pricing_rule(self, quantity, date, uom=None, product=False, start_date=False, end_date=False, **kwargs): """ Return the best pricing rule for the given duration. :param ProductProduct product: a product recordset (containing at most one record) @@ -67,21 +67,21 @@ def _get_best_pricing_rule(self, quantity, date, product=False, start_date=False company = kwargs.get('company', self.env.company) duration_dict = self.env['product.pricelist.item']._compute_duration_vals(start_date, end_date) min_price = float("inf") # positive infinity - available_pricings = self.env['product.pricelist.item']._get_suitable_pricings( + available_pricelist_ids = self.env['product.pricelist.item']._get_suitable_pricings( product or self, pricelist=pricelist ) - for pricing in available_pricings: - unit = pricing.recurrence_id.unit - price = pricing._compute_price_rental(duration_dict[unit], unit, product, quantity, date, start_date, end_date, uom=kwargs.get("uom"),base_price=kwargs.get("base_price")) - if pricing.currency_id != currency: - price = pricing.currency_id._convert( + for pricelist_id in available_pricelist_ids: + unit = pricelist_id.recurrence_id.unit + price = pricelist_id._compute_price_rental(duration_dict[unit], unit, product, quantity, date, start_date, end_date, uom=uom) + if pricelist_id.currency_id != currency: + price = pricelist_id.currency_id._convert( from_amount=price, to_currency=currency, company=company, date=fields.Date.today(), ) if price < min_price: - min_price, best_pricing_rule = price, pricing + min_price, best_pricing_rule = price, pricelist_id return best_pricing_rule def _compute_rental_default(self, duration, unit): diff --git a/pricelist_refactor/models/res_config_settings.py b/pricelist_refactor/models/res_config_settings.py index 7b9abc67ac2..9625b31eca3 100644 --- a/pricelist_refactor/models/res_config_settings.py +++ b/pricelist_refactor/models/res_config_settings.py @@ -1,4 +1,4 @@ -from odoo import api, fields, models, _ +from odoo import api, fields, models from odoo.exceptions import UserError @@ -16,4 +16,4 @@ def _check_pricelist_requirement(self): ('name', '=', 'sale_subscription'), ('state', '=', 'installed') ]): - raise UserError(_("Pricelists are required for Sale Subscription. You cannot disable them.")) + raise UserError("Pricelists are required for Sale Subscription. You cannot disable them.") diff --git a/pricelist_refactor/models/sale_order_line.py b/pricelist_refactor/models/sale_order_line.py index b4856f2b28d..64617cb6b89 100644 --- a/pricelist_refactor/models/sale_order_line.py +++ b/pricelist_refactor/models/sale_order_line.py @@ -18,7 +18,6 @@ def _get_pricelist_price(self): return self.order_id.pricelist_id._get_product_price( self.product_id.with_context(**self._get_product_price_context()), self.product_uom_qty or 1.0, - order_id=self.order_id, currency=self.currency_id, uom=self.product_uom, date=self.order_id.date_order or fields.Date.today(), @@ -27,12 +26,12 @@ def _get_pricelist_price(self): ) elif self.product_template_id.recurring_invoice: if self.order_id.plan_id and self.order_id.pricelist_id: - pricing_item = self.env['product.pricelist.item']._get_first_suitable_recurring_pricing( + pricelist_item_id = self.env['product.pricelist.item']._get_first_suitable_recurring_pricing( self.product_id, self.order_id.plan_id, self.pricelist_id ) else: - pricing_item = self.pricelist_item_id - return pricing_item._compute_price( + pricelist_item_id = self.pricelist_item_id + return pricelist_item_id._compute_price( self.product_id.with_context(**self._get_product_price_context()), self.product_uom_qty or 1.0, self.product_uom, @@ -40,14 +39,13 @@ def _get_pricelist_price(self): self.currency_id, self.order_id.plan_id ) - else: - return self.pricelist_item_id._compute_price( - self.product_id.with_context(**self._get_product_price_context()), - self.product_uom_qty or 1.0, - self.product_uom, - self.order_id.date_order, - self.currency_id, - ) + return self.pricelist_item_id._compute_price( + self.product_id.with_context(**self._get_product_price_context()), + self.product_uom_qty or 1.0, + self.product_uom, + self.order_id.date_order, + self.currency_id, + ) @api.depends('order_id.subscription_state', 'order_id.start_date', 'order_id.rental_start_date', 'order_id.rental_return_date') def _compute_discount(self): @@ -122,11 +120,9 @@ def _compute_discount(self): # therefore, the pricelist didn't apply any discount/change # to the existing sales price. continue - line = line.with_company(line.company_id) pricelist_price = line._get_pricelist_price() base_price = line._get_pricelist_price_before_discount() - if base_price != 0: # Avoid division by zero discount = (base_price - pricelist_price) / base_price * 100 if (discount > 0 and base_price > 0) or (discount < 0 and base_price < 0): @@ -144,5 +140,37 @@ def _compute_pricelist_item_id(self): quantity=line.product_uom_qty or 1.0, uom=line.product_uom, date=line.order_id.date_order, - plan_id=line.order_id.plan_id + plan_id=line.order_id.plan_id, + start_date=line.start_date, + end_date=line.return_date, ) + + def _get_pricelist_price_before_discount(self): + """Compute the price used as base for the pricelist price computation. + + :return: the product sales price in the order currency (without taxes) + :rtype: float + """ + self.ensure_one() + self.product_id.ensure_one() + converted_duration = None + recurrence_id = None + plan_id = None + if self.start_date and self.return_date: + duration_vals = self.pricelist_item_id._compute_duration_vals(self.start_date, self.return_date) + duration = self.pricelist_item_id and duration_vals[self.pricelist_item_id.recurrence_id.unit or 'day'] or 0 + converted_duration = duration + recurrence_id=self.pricelist_item_id.recurrence_id + elif self.order_id.plan_id: + plan_id = self.order_id.plan_id + return self.pricelist_item_id._compute_price_before_discount( + product=self.product_id.with_context(**self._get_product_price_context()), + quantity=self.product_uom_qty or 1.0, + uom=self.product_uom, + date=self.order_id.date_order, + currency=self.currency_id, + plan_id=plan_id, + converted_duration=converted_duration, + recurrence_id=recurrence_id + ) + return super()._get_pricelist_price_before_discount() diff --git a/pricelist_refactor/views/product_pricelist_views.xml b/pricelist_refactor/views/product_pricelist_views.xml index 9a0af20131a..d48a105866e 100644 --- a/pricelist_refactor/views/product_pricelist_views.xml +++ b/pricelist_refactor/views/product_pricelist_views.xml @@ -1,5 +1,6 @@ + sale.subscription.product.pricelist.form.inherit product.pricelist @@ -48,4 +49,5 @@ + diff --git a/pricelist_refactor/views/product_template_views.xml b/pricelist_refactor/views/product_template_views.xml index 80a2274e485..848881c3d23 100644 --- a/pricelist_refactor/views/product_template_views.xml +++ b/pricelist_refactor/views/product_template_views.xml @@ -96,7 +96,7 @@ - diff --git a/pricelist_refactor/views/product_template_views.xml b/pricelist_refactor/views/product_template_views.xml index 848881c3d23..37d57d81f81 100644 --- a/pricelist_refactor/views/product_template_views.xml +++ b/pricelist_refactor/views/product_template_views.xml @@ -5,11 +5,13 @@ product.template + + 1 + @@ -26,6 +28,8 @@ options="{'no_create': True}" /> +
This is default price when no rule is applied.
+
Activate pricelist setting if you want to set specific price rule.
@@ -61,6 +66,7 @@ 'default_compute_price': 'percentage', 'form_view_ref': 'pricelist_refactor.product_pricelist_item_sale_subscription_view' }" + groups="product.group_product_pricelist" > @@ -94,15 +100,17 @@ options="{'no_create': True}" /> +
This is default price when no rule is applied.
+
Activate pricelist setting if you want to set specific price rule.
From d742c8ba4b8f9f8d1917402b009fe360514bb25d Mon Sep 17 00:00:00 2001 From: vrgo-odoo Date: Fri, 21 Mar 2025 18:57:49 +0530 Subject: [PATCH 5/6] [IMP] pricelist_refactor: refactor rental pricelist calculation logic In this commit 1.the minimum quantity for the rental pricelist is now determined based on the minimum quantity of the same pricelist recurrence unit. 2.The rental pricelist calculation logic has been updated. --- .../models/product_pricelist.py | 10 +- .../models/product_pricelist_item.py | 126 +++++++++++------- pricelist_refactor/models/product_template.py | 4 +- pricelist_refactor/models/sale_order_line.py | 35 ++--- pricelist_refactor/tests/test_pricelist.py | 20 ++- .../views/product_pricelist_item_views.xml | 8 ++ 6 files changed, 117 insertions(+), 86 deletions(-) diff --git a/pricelist_refactor/models/product_pricelist.py b/pricelist_refactor/models/product_pricelist.py index 6fab449cdbe..29008787518 100644 --- a/pricelist_refactor/models/product_pricelist.py +++ b/pricelist_refactor/models/product_pricelist.py @@ -79,12 +79,10 @@ def _compute_price_rule( price, currency, self.env.company, date ), pricelist_id.id elif product.recurring_invoice: - results[product.id] = self.env['product.pricelist.item']._get_first_suitable_recurring_pricing( - product, plan=plan_id, pricelist=self - )._compute_price(product, quantity, uom, date, plan_id=plan_id), - self.env['product.pricelist.item']._get_first_suitable_recurring_pricing( - product, plan=plan_id, pricelist=self - ).id + 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, diff --git a/pricelist_refactor/models/product_pricelist_item.py b/pricelist_refactor/models/product_pricelist_item.py index 9e821098603..b9a87485ea0 100644 --- a/pricelist_refactor/models/product_pricelist_item.py +++ b/pricelist_refactor/models/product_pricelist_item.py @@ -19,7 +19,7 @@ class ProductPricelistItem(models.Model): recurrence_id = fields.Many2one(comodel_name='sale.temporal.recurrence', string="Renting Period") @api.model - def _get_first_suitable_recurring_pricing(self, product, plan=None, pricelist=None): + 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" @@ -28,49 +28,27 @@ def _get_first_suitable_recurring_pricing(self, product, plan=None, pricelist=No 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.pricelist_id == pricelist \ - and (is_product_template or product_pricelist_item_id._applies_to(product_sudo)): + if product_pricelist_item_id._is_applies_to(product, product_uom_qty, date, is_product_template, plan, pricelist): return product_pricelist_item_id - if not first_pricelist_item_id and product_pricelist_item_id.pricelist_id \ - and (is_product_template or product_pricelist_item_id._applies_to(product_sudo)): - # If price list and current pricing is not part of it, - # We store the first one to return if not pricing matching the price list is found. - first_pricelist_item_id = product_pricelist_item_id return first_pricelist_item_id @api.model - def _get_first_suitable_rental_pricing(self, product, recurrence_id=None, pricelist=None): + 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.pricelist_id == pricelist \ - and (is_product_template or product_pricelist_item_id._applies_to(product_sudo)): + if product_pricelist_item_id._applies_to_rental(product, pricelist, is_product_template, duration_vals, date): return product_pricelist_item_id - if not first_pricelist_item_id and product_pricelist_item_id.pricelist_id \ - and (is_product_template or product_pricelist_item_id._applies_to(product_sudo)): - # If price list and current pricing is not part of it, - # We store the first one to return if not pricing matching the price list is found. - first_pricelist_item_id = product_pricelist_item_id return first_pricelist_item_id - 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) - 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, converted_duration=converted_duration) - @api.model def _compute_duration_vals(self, start_date, end_date): """Compute duration in various temporal units.""" @@ -88,40 +66,86 @@ def _compute_duration_vals(self, start_date, end_date): return vals @api.model - def _get_suitable_pricings(self, product, pricelist=None, first=False): + 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: - if pricing.pricelist_id == pricelist\ - and (is_product_template or pricing._applies_to(product)): + 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 - for pricing in product.rental_pricelist_rule_ids: - if not pricing.pricelist_id and (is_product_template or pricing._applies_to(product)): - if first: - return pricing - available_pricings |= pricing + 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 + ) + ) - return available_pricings + 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(self, product): + 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 """ - self.ensure_one() return ( - self.product_tmpl_id == product.product_tmpl_id + 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 ( - self.applied_on == "1_product" - or product == self.product_id)) + 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, *kwargs): + 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() @@ -158,7 +182,7 @@ def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=No 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, converted_duration=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 @@ -166,10 +190,10 @@ def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=No 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) + 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 @@ -189,24 +213,26 @@ def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=No return price - def _compute_base_price(self, product, quantity, uom, date, currency, plan_id=None, recurrence_id=None, converted_duration=None): + 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, plan_id, self.base_pricelist_id + 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, recurrence_id, self.base_pricelist_id + 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: diff --git a/pricelist_refactor/models/product_template.py b/pricelist_refactor/models/product_template.py index 88401f42c44..f630e05c8cd 100644 --- a/pricelist_refactor/models/product_template.py +++ b/pricelist_refactor/models/product_template.py @@ -47,7 +47,7 @@ class ProductTemplate(models.Model): ('recurrence_id', '!=', False) ] ) - + default_rent_unit = fields.Many2one(comodel_name='sale.temporal.recurrence', string="Default Sale Unit") def _get_best_pricing_rule(self, quantity, date, uom=None, product=False, start_date=False, end_date=False, **kwargs): @@ -68,7 +68,7 @@ def _get_best_pricing_rule(self, quantity, date, uom=None, product=False, start_ duration_dict = self.env['product.pricelist.item']._compute_duration_vals(start_date, end_date) min_price = float("inf") # positive infinity available_pricelist_ids = self.env['product.pricelist.item']._get_suitable_pricings( - product or self, pricelist=pricelist + product or self, pricelist, start_date, end_date, date ) for pricelist_id in available_pricelist_ids: unit = pricelist_id.recurrence_id.unit diff --git a/pricelist_refactor/models/sale_order_line.py b/pricelist_refactor/models/sale_order_line.py index 64617cb6b89..10643122b4e 100644 --- a/pricelist_refactor/models/sale_order_line.py +++ b/pricelist_refactor/models/sale_order_line.py @@ -24,27 +24,18 @@ def _get_pricelist_price(self): start_date=self.start_date, end_date=self.return_date, ) - elif self.product_template_id.recurring_invoice: + elif self.product_template_id.recurring_invoice : if self.order_id.plan_id and self.order_id.pricelist_id: - pricelist_item_id = self.env['product.pricelist.item']._get_first_suitable_recurring_pricing( - self.product_id, self.order_id.plan_id, self.pricelist_id + self.pricelist_item_id = self.env['product.pricelist.item']._get_first_suitable_recurring_pricing( + self.product_id, self.product_uom_qty or 1.0, self.order_id.date_order, self.order_id.plan_id, self.pricelist_id ) - else: - pricelist_item_id = self.pricelist_item_id - return pricelist_item_id._compute_price( - self.product_id.with_context(**self._get_product_price_context()), - self.product_uom_qty or 1.0, - self.product_uom, - self.order_id.date_order, - self.currency_id, - self.order_id.plan_id - ) return self.pricelist_item_id._compute_price( self.product_id.with_context(**self._get_product_price_context()), self.product_uom_qty or 1.0, self.product_uom, self.order_id.date_order, self.currency_id, + self.order_id.plan_id, ) @api.depends('order_id.subscription_state', 'order_id.start_date', 'order_id.rental_start_date', 'order_id.rental_return_date') @@ -153,24 +144,14 @@ def _get_pricelist_price_before_discount(self): """ self.ensure_one() self.product_id.ensure_one() - converted_duration = None - recurrence_id = None - plan_id = None - if self.start_date and self.return_date: - duration_vals = self.pricelist_item_id._compute_duration_vals(self.start_date, self.return_date) - duration = self.pricelist_item_id and duration_vals[self.pricelist_item_id.recurrence_id.unit or 'day'] or 0 - converted_duration = duration - recurrence_id=self.pricelist_item_id.recurrence_id - elif self.order_id.plan_id: - plan_id = self.order_id.plan_id return self.pricelist_item_id._compute_price_before_discount( product=self.product_id.with_context(**self._get_product_price_context()), quantity=self.product_uom_qty or 1.0, uom=self.product_uom, date=self.order_id.date_order, currency=self.currency_id, - plan_id=plan_id, - converted_duration=converted_duration, - recurrence_id=recurrence_id + plan_id=self.order_id.plan_id, + recurrence_id=self.pricelist_item_id.recurrence_id, + start_date=self.start_date, + end_date=self.return_date, ) - return super()._get_pricelist_price_before_discount() diff --git a/pricelist_refactor/tests/test_pricelist.py b/pricelist_refactor/tests/test_pricelist.py index 383d48a53e7..8889dcffdf1 100644 --- a/pricelist_refactor/tests/test_pricelist.py +++ b/pricelist_refactor/tests/test_pricelist.py @@ -87,6 +87,14 @@ def setUpClass(cls): 'product_tmpl_id': cls.test_rent_template_id.id, 'applied_on': '1_product' }), + Command.create({ + 'compute_price': 'fixed', + 'recurrence_id': cls.recurrence_hourly.id, + 'fixed_price': 5, + 'min_quantity': 2, + 'product_tmpl_id': cls.test_rent_template_id.id, + 'applied_on': '1_product' + }), Command.create({ 'compute_price': 'fixed', 'recurrence_id': cls.recurrence_daily.id, @@ -215,7 +223,17 @@ def test_rental_pricelist(self): self.assertEqual( self.rent_order.order_line[0].price_subtotal, - 90 + 45 + ) + + self.rent_order.write({ + 'rental_start_date': fields.Datetime.now(), + 'rental_return_date': fields.Datetime.now() + relativedelta(hours=1), + }) + self.rent_order._recompute_prices() + self.assertEqual( + self.rent_order.order_line[0].price_subtotal, + 10 ) self.rent_order.write({ diff --git a/pricelist_refactor/views/product_pricelist_item_views.xml b/pricelist_refactor/views/product_pricelist_item_views.xml index 06d1e79bb95..c0f31b05ff7 100644 --- a/pricelist_refactor/views/product_pricelist_item_views.xml +++ b/pricelist_refactor/views/product_pricelist_item_views.xml @@ -60,6 +60,14 @@ + +
+ + + + +
+
From 68abf2e505354cf7674575178419744c219aa983 Mon Sep 17 00:00:00 2001 From: vrgo-odoo Date: Tue, 25 Mar 2025 18:54:02 +0530 Subject: [PATCH 6/6] [IMP] pricelist_refactor: add logic for category in pricelist In this commit 1.If no pricelist item is found for a specific product, fallback to finding a pricelist item based on the product category. 2.logic improvement for calculations. --- .../models/product_pricelist.py | 28 ++++- .../models/product_pricelist_item.py | 110 +++++++++--------- pricelist_refactor/models/product_template.py | 6 +- pricelist_refactor/models/sale_order_line.py | 6 +- 4 files changed, 81 insertions(+), 69 deletions(-) diff --git a/pricelist_refactor/models/product_pricelist.py b/pricelist_refactor/models/product_pricelist.py index 29008787518..63e06bfe277 100644 --- a/pricelist_refactor/models/product_pricelist.py +++ b/pricelist_refactor/models/product_pricelist.py @@ -42,7 +42,7 @@ class ProductPricelist(models.Model): ) def _compute_price_rule( - self, products, quantity, currency=None, date=False, start_date=None, end_date=None, plan_id=None, uom=None, **kwargs + self, products, quantity, currency=None, date=False, start_date=None, end_date=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 @@ -78,11 +78,7 @@ def _compute_price_rule( 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, @@ -91,6 +87,26 @@ def _compute_price_rule( ), } + def _get_applicable_rules_domain(self, products, date, plan_id=None, **kwargs): + if plan_id: + self and self.ensure_one() # self is at most one record + if products._name == 'product.template': + templates_domain = ('product_tmpl_id', 'in', products.ids) + products_domain = ('product_id.product_tmpl_id', 'in', products.ids) + else: + templates_domain = ('product_tmpl_id', 'in', products.product_tmpl_id.ids) + products_domain = ('product_id', 'in', products.ids) + return [ + ('pricelist_id', '=', self.id), + ('plan_id', '=', plan_id.id), + '|', ('categ_id', '=', False), ('categ_id', 'parent_of', products.categ_id.ids), + '|', ('product_tmpl_id', '=', False), templates_domain, + '|', ('product_id', '=', False), products_domain, + '|', ('date_start', '=', False), ('date_start', '<=', date), + '|', ('date_end', '=', False), ('date_end', '>=', date), + ] + return super()._get_applicable_rules_domain(products, 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) diff --git a/pricelist_refactor/models/product_pricelist_item.py b/pricelist_refactor/models/product_pricelist_item.py index b9a87485ea0..585cf26f2df 100644 --- a/pricelist_refactor/models/product_pricelist_item.py +++ b/pricelist_refactor/models/product_pricelist_item.py @@ -19,21 +19,7 @@ class ProductPricelistItem(models.Model): 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): + def _get_first_suitable_rental_pricelist_id(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" @@ -45,8 +31,27 @@ def _get_first_suitable_rental_pricing(self, product, date, start_date, end_date 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): + if product_pricelist_item_id._is_applies_to_rental(product, pricelist, is_product_template, duration_vals, date): return product_pricelist_item_id + + # if no pricelist item is found for the product, search for a pricelist item based on the product category. + pricelist_item_ids = self.env['product.pricelist.item']._read_group( + domain=[ + ('pricelist_id', '=', pricelist.id), + ('recurrence_id', '!=', False), + '|', ('categ_id', 'parent_of', product.categ_id.ids), + '&', ('product_tmpl_id', '=', False), ('product_id', '=', False), + '|', ('date_start', '=', False), ('date_start', '<=', date), + '|', ('date_end', '=', False), ('date_end', '>=', date) + ], + groupby=['categ_id'], + aggregates=['id:recordset'], + limit=1 + ) + if pricelist_item_ids: + for pricelist_item_id in pricelist_item_ids[0][1]: + if recurrence_id and pricelist_item_id.recurrence_id == recurrence_id and pricelist_item_id.min_quantity <= duration_vals: + return pricelist_item_id return first_pricelist_item_id @api.model @@ -66,44 +71,39 @@ def _compute_duration_vals(self, start_date, end_date): return vals @api.model - def _get_suitable_pricings(self, product, pricelist, start_date, end_date, date, first=False): + def _get_suitable_pricelist_ids(self, product, pricelist, start_date, end_date, date): """Get the suitable pricings for a product.""" is_product_template = product._name == "product.template" - available_pricings = self.env['product.pricelist.item'] + available_pricelist_ids = 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 - ) - ) + for price_rule_id in product.rental_pricelist_rule_ids: + duration_vals = self._compute_duration_vals(start_date, end_date)[price_rule_id.recurrence_id.unit] + if price_rule_id.pricelist_id == pricelist \ + and (is_product_template or price_rule_id._is_applies_to_rental(product, pricelist, is_product_template, duration_vals, date)): + available_pricelist_ids |= price_rule_id + if not available_pricelist_ids: + self and self.ensure_one() # self is at most one record + pricelist_item_ids = self.env['product.pricelist.item']._read_group( + domain=[ + ('pricelist_id', '=', pricelist.id), + ('recurrence_id', '!=', False), + '|', ('categ_id', 'parent_of', product.categ_id.ids), + '&', ('product_tmpl_id', '=', False), ('product_id', '=', False), + '|', ('date_start', '=', False), ('date_start', '<=', date), + '|', ('date_end', '=', False), ('date_end', '>=', date) + ], + groupby=['categ_id'], + aggregates=['id:recordset'], + limit=1 ) - 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 - ) - ) + if pricelist_item_ids: + for pricelist_id in pricelist_item_ids[0][1]: + duration_vals = self._compute_duration_vals(start_date, end_date)[pricelist_id.recurrence_id.unit] + if pricelist_id.min_quantity <= duration_vals: + available_pricelist_ids |= pricelist_id + else: + break + return available_pricelist_ids 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.""" @@ -118,7 +118,7 @@ def _compute_price_rental(self, duration, unit, product, quantity, date, start_d 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): + def _is_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. @@ -190,7 +190,7 @@ def _compute_price(self, product, quantity, uom, date, currency=None, plan_id=No 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) + 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) @@ -219,9 +219,7 @@ def _compute_base_price(self, product, quantity, uom, date, currency, plan_id=No 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) + price = self.base_pricelist_id._get_product_price(product, quantity, uom, date, plan_id=plan_id) src_currency = self.base_pricelist_id.currency_id if src_currency != currency: price = src_currency._convert(price, currency, self.env.company, date, round=False) @@ -231,7 +229,7 @@ def _compute_base_price(self, product, quantity, uom, date, currency, plan_id=No 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( + price = self._get_first_suitable_rental_pricelist_id( 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 diff --git a/pricelist_refactor/models/product_template.py b/pricelist_refactor/models/product_template.py index f630e05c8cd..0287f6a571c 100644 --- a/pricelist_refactor/models/product_template.py +++ b/pricelist_refactor/models/product_template.py @@ -60,16 +60,18 @@ def _get_best_pricing_rule(self, quantity, date, uom=None, product=False, start_ """ self.ensure_one() best_pricing_rule = self.env['product.pricelist.item'] - if not self.rental_pricelist_rule_ids or not (start_date and end_date): + if not (start_date and end_date): return best_pricing_rule pricelist = kwargs.get('pricelist', self.env['product.pricelist']) currency = kwargs.get('currency', self.currency_id) company = kwargs.get('company', self.env.company) duration_dict = self.env['product.pricelist.item']._compute_duration_vals(start_date, end_date) min_price = float("inf") # positive infinity - available_pricelist_ids = self.env['product.pricelist.item']._get_suitable_pricings( + available_pricelist_ids = self.env['product.pricelist.item']._get_suitable_pricelist_ids( product or self, pricelist, start_date, end_date, date ) + if not available_pricelist_ids: + return best_pricing_rule for pricelist_id in available_pricelist_ids: unit = pricelist_id.recurrence_id.unit price = pricelist_id._compute_price_rental(duration_dict[unit], unit, product, quantity, date, start_date, end_date, uom=uom) diff --git a/pricelist_refactor/models/sale_order_line.py b/pricelist_refactor/models/sale_order_line.py index 10643122b4e..232a54b2cce 100644 --- a/pricelist_refactor/models/sale_order_line.py +++ b/pricelist_refactor/models/sale_order_line.py @@ -24,11 +24,6 @@ def _get_pricelist_price(self): start_date=self.start_date, end_date=self.return_date, ) - elif self.product_template_id.recurring_invoice : - if self.order_id.plan_id and self.order_id.pricelist_id: - self.pricelist_item_id = self.env['product.pricelist.item']._get_first_suitable_recurring_pricing( - self.product_id, self.product_uom_qty or 1.0, self.order_id.date_order, self.order_id.plan_id, self.pricelist_id - ) return self.pricelist_item_id._compute_price( self.product_id.with_context(**self._get_product_price_context()), self.product_uom_qty or 1.0, @@ -121,6 +116,7 @@ def _compute_discount(self): # otherwise it's a surcharge which shouldn't be shown to the customer line.discount = discount + @api.depends('order_id.plan_id') def _compute_pricelist_item_id(self): for line in self: if not line.product_id or line.display_type or not line.order_id.pricelist_id: