317 lines
14 KiB
Python
317 lines
14 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||
|
||
from math import floor
|
||
|
||
from odoo import _, api, fields, models
|
||
from odoo.exceptions import UserError
|
||
from odoo.fields import Domain
|
||
from odoo.http import request
|
||
from odoo.tools import format_amount
|
||
|
||
|
||
class ProductTemplate(models.Model):
|
||
_inherit = 'product.template'
|
||
|
||
@api.constrains('optional_product_ids')
|
||
def _constraints_optional_product_ids(self):
|
||
for template in self:
|
||
if not template.recurring_invoice:
|
||
continue
|
||
plan_ids = set(template.subscription_rule_ids.plan_id.ids)
|
||
for optional_template in template.optional_product_ids:
|
||
if not optional_template.recurring_invoice:
|
||
continue
|
||
optional_plan_ids = optional_template.subscription_rule_ids.plan_id.ids
|
||
if not plan_ids.intersection(optional_plan_ids):
|
||
raise UserError(_('You cannot have an optional product that has a no common pricing\'s plan.'))
|
||
|
||
def _website_can_be_added(self, product=None) -> bool:
|
||
"""Return whether the product/template can be added to the active SO."""
|
||
self.ensure_one()
|
||
if not self.recurring_invoice:
|
||
return True
|
||
|
||
if request.cart._has_one_time_sale() and not self.allow_one_time_sale:
|
||
return False
|
||
|
||
has_pricing = bool(
|
||
self._get_recurring_pricing(
|
||
pricelist=request.pricelist,
|
||
variant=product,
|
||
plan_id=request.cart.plan_id.id,
|
||
)
|
||
)
|
||
if not has_pricing and not product:
|
||
# If pricings are only defined by variant, there are no pricing applicable to the
|
||
# template itself. In this situation, we need to search for a pricing on a variant
|
||
# otherwise customers wouldn't be able to add the product to their cart.
|
||
has_pricing = bool(
|
||
self._get_recurring_pricing(
|
||
pricelist=request.pricelist,
|
||
variant=self.product_variant_id,
|
||
plan_id=request.cart.plan_id.id,
|
||
)
|
||
)
|
||
|
||
return has_pricing or (
|
||
self.allow_one_time_sale
|
||
and not request.cart.plan_id
|
||
)
|
||
|
||
def _get_recurring_pricings(self, pricelist, variant=None, quantity=1.0):
|
||
"""Return the first pricing applicable for each of the available subscription plans."""
|
||
self.ensure_one()
|
||
|
||
pricings = self.env['product.pricelist.item']
|
||
domain = pricelist._get_applicable_rules_domain(
|
||
products=variant or self,
|
||
date=fields.Datetime.now(),
|
||
any_plan=True,
|
||
)
|
||
|
||
all_pricings = self.env['product.pricelist.item'].search(
|
||
domain, order=self.env['product.pricelist.item']._get_recurring_rules_order()
|
||
)
|
||
if pricelist:
|
||
# Add the rules not restricted to a specific pricelist, only for the plans that had no
|
||
# rule for the current pricelist.
|
||
domain = self.env['product.pricelist']._get_applicable_rules_domain(
|
||
products=variant or self,
|
||
date=fields.Datetime.now(),
|
||
any_plan=True,
|
||
)
|
||
all_pricings |= self.env['product.pricelist.item'].search(
|
||
Domain.AND([domain, [('plan_id', 'not in', all_pricings.plan_id.ids)]]),
|
||
order=self.env['product.pricelist.item']._get_recurring_rules_order()
|
||
)
|
||
|
||
found_plan_ids = set()
|
||
for pricing in all_pricings:
|
||
if (
|
||
pricing.plan_id.sudo().active
|
||
and (plan_id := pricing.plan_id.id) not in found_plan_ids
|
||
# No need for uom conversion since multi-uom is not supported for recurring
|
||
# products atm.
|
||
and pricing._is_applicable_for(product=variant or self, qty_in_product_uom=quantity)
|
||
):
|
||
found_plan_ids.add(plan_id)
|
||
pricings |= pricing
|
||
|
||
return pricings
|
||
|
||
def _get_additionnal_combination_info(self, product_or_template, quantity, uom, date, website):
|
||
res = super()._get_additionnal_combination_info(product_or_template, quantity, uom, date, website)
|
||
|
||
if not product_or_template.recurring_invoice:
|
||
return res
|
||
|
||
product = (product_or_template.is_product_variant and product_or_template) or self.env['product.product']
|
||
pricings = self._get_recurring_pricings(pricelist=request.pricelist, variant=product, quantity=quantity)
|
||
|
||
res['list_price'] = res['price'] # No pricelist discount for subscription prices
|
||
|
||
if not pricings:
|
||
res.update({
|
||
'is_subscription': True,
|
||
'is_plan_possible': False,
|
||
'pricings': [],
|
||
'allow_one_time_sale': not request.cart.plan_id and self.allow_one_time_sale,
|
||
})
|
||
return res
|
||
|
||
to_year = {'year': 1, 'month': 12, 'week': 52}
|
||
translation_mapping = {
|
||
'year': (self.env._('year'), self.env._('years')),
|
||
'month': (self.env._('month'), self.env._('months')),
|
||
'week': (self.env._('week'), self.env._('weeks')),
|
||
}
|
||
|
||
# Find the plan with the shortest billing period to use as base for comparison
|
||
base_plan = min(pricings.sudo().plan_id, key=lambda x: 1 / to_year[x.billing_period_unit])
|
||
minimum_period = base_plan.billing_period_unit
|
||
|
||
# Compute base period price and max price for discount calculation
|
||
base_plan_pricings = pricings.filtered(lambda pr: pr.plan_id == base_plan)
|
||
base_period_price = min(base_plan_pricings.mapped('fixed_price'), default=0.0) / base_plan.billing_period_value
|
||
max_price = max(pricings.mapped('fixed_price'), default=0.0)
|
||
|
||
currency = website.currency_id
|
||
requested_plan = request and request.params.get('plan_id')
|
||
requested_plan_id = requested_plan and requested_plan.isdigit() and int(requested_plan)
|
||
requested_plan_id = requested_plan_id or request.cart.plan_id.id
|
||
if requested_plan_id:
|
||
chosen_pricing = pricings.filtered(lambda pricing: pricing.plan_id.id == requested_plan_id) or pricings[0]
|
||
else:
|
||
chosen_pricing = pricings[0]
|
||
|
||
sales_price = res['price']
|
||
|
||
def _get_pricing_data(pricing):
|
||
if not pricing:
|
||
return {}
|
||
|
||
price = pricing._compute_price(
|
||
product=product_or_template,
|
||
quantity=quantity or 1.0,
|
||
date=date,
|
||
uom=product_or_template.uom_id,
|
||
currency=currency,
|
||
plan_id=pricing.plan_id.id,
|
||
)
|
||
|
||
if res.get('product_taxes', False):
|
||
price = self.env['product.template']._apply_taxes_to_price(
|
||
price, currency, res['product_taxes'], res['taxes'], product_or_template,
|
||
)
|
||
|
||
price_format = format_amount(self.env, amount=price, currency=currency)
|
||
pricing_plan_sudo = pricing.plan_id.sudo() # Not accessible to public users
|
||
price_in_minimum_period = (
|
||
price
|
||
/ pricing_plan_sudo.billing_period_value
|
||
* to_year[pricing_plan_sudo.billing_period_unit]
|
||
/ to_year[minimum_period]
|
||
)
|
||
|
||
if product_or_template.type == 'consu':
|
||
# For consumable products, use billing period (e.g., "3 months") instead of plan name
|
||
value = pricing.plan_id.billing_period_value
|
||
unit = pricing.plan_id.billing_period_unit
|
||
singular_unit, plural_unit = translation_mapping.get(unit)
|
||
unit_label = singular_unit if value == 1 else plural_unit
|
||
table_name = f"{value if value != 1 else ''} {unit_label}".strip()
|
||
else:
|
||
# For non-consumable products, use plan name with non-breaking spaces
|
||
table_name = pricing.plan_id.name.replace(" ", "\u00A0")
|
||
|
||
minimum_billing_period, _ = translation_mapping.get(minimum_period)
|
||
pricing_data = {
|
||
'plan_id': pricing_plan_sudo.id,
|
||
'price': f"{pricing.plan_id.name}: {price_format}",
|
||
'price_value': price,
|
||
'table_price': price_format,
|
||
'table_name': table_name,
|
||
'to_minimum_billing_period': f'{format_amount(self.env, amount=price_in_minimum_period, currency=currency)}'
|
||
f' / {minimum_billing_period}',
|
||
'can_be_added': request.cart.plan_id.id in (pricing_plan_sudo.id, False),
|
||
}
|
||
|
||
# Calculate discount percentage
|
||
if pricing.compute_price == 'percentage': # Percentage discount: use the value directly
|
||
discount = pricing.percent_price
|
||
elif product_or_template.allow_one_time_sale and 0 < price <= sales_price: # One-time sale: compare against sale price
|
||
discount = ((sales_price - price) * 100) / sales_price
|
||
elif (product_or_template.type == 'consu' and 0 < price <= max_price): # Consumables: compare against max price
|
||
discount = ((max_price - price) * 100) / max_price
|
||
elif 0 < price_in_minimum_period <= base_period_price: # Non-consumables: compare against base period price
|
||
discount = ((base_period_price - price_in_minimum_period) * 100) / base_period_price
|
||
else:
|
||
discount = 0.0
|
||
pricing_data['discounted_price'] = floor(discount) if discount > 0 else 0.0
|
||
|
||
return pricing_data
|
||
|
||
default_pricing_data = _get_pricing_data(chosen_pricing)
|
||
|
||
pricing_details = [
|
||
_get_pricing_data(pricing)
|
||
for pricing in pricings
|
||
]
|
||
|
||
unit_price = default_pricing_data.get('price_value', 0)
|
||
return {
|
||
**res,
|
||
'is_subscription': True,
|
||
'pricings': pricing_details,
|
||
'is_plan_possible': bool(chosen_pricing),
|
||
'price': unit_price,
|
||
'subscription_default_pricing_price': default_pricing_data.get('price', ''),
|
||
'subscription_default_pricing_plan_id': default_pricing_data.get('plan_id', False),
|
||
'subscription_pricing_select': (product_or_template.allow_one_time_sale or len(pricings) > 1) and not request.cart.plan_id,
|
||
'prevent_zero_price_sale': website.prevent_zero_price_sale and currency.is_zero(
|
||
unit_price,
|
||
),
|
||
'allow_one_time_sale': not request.cart.plan_id and self.allow_one_time_sale,
|
||
'allow_recurring': not request.cart._has_one_time_sale(),
|
||
'product_type': product_or_template.type, # Used to change pricing text in template based on product type
|
||
"temporal_unit_display": chosen_pricing.plan_id.sudo().billing_period_display_sentence if chosen_pricing else "",
|
||
}
|
||
|
||
# Search bar
|
||
def _search_render_results_prices(self, mapping, combination_info):
|
||
if not combination_info.get('is_subscription'):
|
||
return super()._search_render_results_prices(mapping, combination_info)
|
||
|
||
if not combination_info['is_plan_possible']:
|
||
return '', 0
|
||
|
||
return self.env['ir.ui.view']._render_template(
|
||
'website_sale_subscription.subscription_search_result_price',
|
||
values={
|
||
'subscription_default_pricing_price': combination_info['subscription_default_pricing_price'],
|
||
}
|
||
), 0
|
||
|
||
def _get_sales_prices(self, website):
|
||
prices = super()._get_sales_prices(website)
|
||
|
||
pricelist = request.pricelist
|
||
currency = website.currency_id
|
||
fiscal_position_sudo = request.fiscal_position
|
||
so_plan_id = request.cart.plan_id.id
|
||
date = fields.Date.context_today(self)
|
||
|
||
for template in self:
|
||
if not template.recurring_invoice:
|
||
continue
|
||
|
||
pricing = template._get_recurring_pricing(pricelist=pricelist, plan_id=so_plan_id)
|
||
if not pricing:
|
||
prices[template.id].update({
|
||
'is_subscription': True,
|
||
'is_plan_possible': False,
|
||
})
|
||
continue
|
||
|
||
unit_price = pricing._compute_price(
|
||
product=template,
|
||
quantity=1.0,
|
||
date=date,
|
||
uom=template.uom_id,
|
||
currency=currency,
|
||
plan_id=pricing.plan_id.id,
|
||
)
|
||
|
||
# taxes application
|
||
product_taxes = template.sudo().taxes_id._filter_taxes_by_company(self.env.company)
|
||
if product_taxes:
|
||
taxes = fiscal_position_sudo.map_tax(product_taxes)
|
||
unit_price = self.env['product.template']._apply_taxes_to_price(
|
||
unit_price, currency, product_taxes, taxes, template)
|
||
|
||
plan_sudo = pricing.plan_id.sudo()
|
||
prices[template.id].update({
|
||
'is_subscription': True,
|
||
'price_reduce': unit_price,
|
||
'is_plan_possible': True, # The plan can only be valid at this point
|
||
'temporal_unit_display': plan_sudo.billing_period_display_sentence,
|
||
})
|
||
|
||
return prices
|
||
|
||
def _get_contextual_pricelist(self):
|
||
pricelist = super()._get_contextual_pricelist()
|
||
if plan_id := self.env.context.get('plan_id'):
|
||
# This pricelist is used in _get_contextual_price to compute
|
||
# the price of a product according to the plan_id selected
|
||
return pricelist.with_context(plan_id=plan_id)
|
||
return pricelist
|
||
|
||
def _website_show_quick_add(self):
|
||
self.ensure_one()
|
||
return super()._website_show_quick_add() and self._website_can_be_added()
|
||
|
||
def _can_be_added_to_cart(self):
|
||
self.ensure_one()
|
||
return super()._can_be_added_to_cart() and self._website_can_be_added()
|