Initial commit: Sale packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:49 +02:00
commit 14e3d26998
6469 changed files with 2479670 additions and 0 deletions

View file

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import loyalty_card
from . import loyalty_program
from . import loyalty_rule
from . import sale_order_line
from . import sale_order

View file

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class LoyaltyCard(models.Model):
_inherit = 'loyalty.card'
def action_coupon_share(self):
self.ensure_one()
return self.env['coupon.share'].create_share_action(coupon=self)

View file

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class LoyaltyProgram(models.Model):
_name = 'loyalty.program'
_inherit = ['loyalty.program', 'website.multi.mixin']
ecommerce_ok = fields.Boolean("Available on Website", default=True)
def action_program_share(self):
self.ensure_one()
return self.env['coupon.share'].create_share_action(program=self)

View file

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class LoyaltyRule(models.Model):
_inherit = 'loyalty.rule'
website_id = fields.Many2one(related='program_id.website_id', store=True)
# NOTE: is this sufficient?
@api.constrains('code', 'website_id')
def _constrains_code(self):
#Programs with the same code are allowed to coexist as long
# as they are not both accessible from a website.
with_code = self.filtered(lambda r: r.mode == 'with_code')
mapped_codes = with_code.mapped('code')
read_result = self.env['loyalty.rule'].search_read(
[('website_id', 'in', [False] + [w.id for w in self.website_id]),
('mode', '=', 'with_code'), ('code', 'in', mapped_codes),
('id', 'not in', with_code.ids)],
fields=['code', 'website_id']) + [{'code': p.code, 'website_id': p.website_id} for p in with_code]
existing_codes = set()
for res in read_result:
website_checks = (res['website_id'], False) if res['website_id'] else (False,)
for website in website_checks:
val = (res['code'], website)
if val in existing_codes:
raise ValidationError(_('The promo code must be unique.'))
existing_codes.add(val)
# Prevent coupons and programs from sharing a code
if self.env['loyalty.card'].search_count([('code', 'in', mapped_codes)]):
raise ValidationError(_('A coupon with the same code was found.'))

View file

@ -0,0 +1,215 @@
# -*- coding: utf-8 -*-
from collections import defaultdict
from datetime import timedelta
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.http import request
class SaleOrder(models.Model):
_inherit = "sale.order"
# List of disabled rewards for automatic claim
disabled_auto_rewards = fields.Many2many("loyalty.reward", relation="sale_order_disabled_auto_rewards_rel")
def _get_program_domain(self):
res = super()._get_program_domain()
# Replace `sale_ok` leaf with `ecommerce_ok` if order is linked to a website
if self.website_id:
for idx, leaf in enumerate(res):
if leaf[0] != 'sale_ok':
continue
res[idx] = ('ecommerce_ok', '=', True)
return expression.AND([res, [('website_id', 'in', (self.website_id.id, False))]])
return res
def _get_trigger_domain(self):
res = super()._get_trigger_domain()
# Replace `sale_ok` leaf with `ecommerce_ok` if order is linked to a website
if self.website_id:
for idx, leaf in enumerate(res):
if leaf[0] != 'program_id.sale_ok':
continue
res[idx] = ('program_id.ecommerce_ok', '=', True)
return expression.AND([res, [('program_id.website_id', 'in', (self.website_id.id, False))]])
return res
def _try_pending_coupon(self):
if not request:
return False
pending_coupon_code = request.session.get('pending_coupon_code')
if pending_coupon_code:
status = self._try_apply_code(pending_coupon_code)
if 'error' not in status: # Returns an array if everything went right
request.session.pop('pending_coupon_code')
if len(status) == 1:
coupon, rewards = next(iter(status.items()))
if len(rewards) == 1 and not rewards.multi_product:
self._apply_program_reward(rewards, coupon)
return status
return True
def _update_programs_and_rewards(self):
for order in self:
order._try_pending_coupon()
return super()._update_programs_and_rewards()
def _auto_apply_rewards(self):
"""
Tries to auto apply claimable rewards.
It must answer to the following rules:
- Must not be from a nominative program
- The reward must be the only reward of the program
- The reward may not be a multi product reward
Returns True if any reward was claimed else False
"""
self.ensure_one()
claimed_reward_count = 0
claimable_rewards = self._get_claimable_rewards()
for coupon, rewards in claimable_rewards.items():
if len(coupon.program_id.reward_ids) != 1 or\
coupon.program_id.is_nominative or\
(rewards.reward_type == 'product' and rewards.multi_product) or\
rewards in self.disabled_auto_rewards or\
rewards in self.order_line.reward_id:
continue
try:
res = self._apply_program_reward(rewards, coupon)
if 'error' not in res:
claimed_reward_count += 1
except UserError:
pass
return bool(claimed_reward_count)
def _compute_website_order_line(self):
""" This method will merge multiple discount lines generated by a same program
into a single one (temporary line with `new()`).
This case will only occur when the program is a discount applied on multiple
products with different taxes.
In this case, each taxes will have their own discount line. This is required
to have correct amount of taxes according to the discount.
But we wan't these lines to be `visually` merged into a single one in the
e-commerce since the end user should only see one discount line.
This is only possible since we don't show taxes in cart.
eg:
line 1: 10% discount on product with tax `A` - $15
line 2: 10% discount on product with tax `B` - $11.5
line 3: 10% discount on product with tax `C` - $10
would be `hidden` and `replaced` by
line 1: 10% discount - $36.5
Note: The line will be created without tax(es) and the amount will be computed
depending if B2B or B2C is enabled.
"""
super()._compute_website_order_line()
for order in self:
grouped_order_lines = defaultdict(lambda: self.env['sale.order.line'])
for line in order.order_line:
if line.reward_id and line.coupon_id:
grouped_order_lines[(line.reward_id, line.coupon_id, line.reward_identifier_code)] |= line
new_lines = self.env['sale.order.line']
for lines in grouped_order_lines.values():
if lines.reward_id.reward_type != 'discount':
continue
new_lines += self.env['sale.order.line'].new({
'product_id': lines[0].product_id.id,
'tax_id': False,
'price_unit': sum(lines.mapped('price_unit')),
'price_subtotal': sum(lines.mapped('price_subtotal')),
'price_total': sum(lines.mapped('price_total')),
'discount': 0.0,
'name': lines[0].name_short if lines.reward_id.reward_type != 'product' else lines[0].name,
'product_uom_qty': 1,
'product_uom': lines[0].product_uom.id,
'order_id': order.id,
'is_reward_line': True,
'coupon_id': lines.coupon_id,
'reward_id': lines.reward_id,
})
if new_lines:
order.website_order_line += new_lines
def _compute_cart_info(self):
super(SaleOrder, self)._compute_cart_info()
for order in self:
reward_lines = order.website_order_line.filtered(lambda line: line.is_reward_line)
order.cart_quantity -= int(sum(reward_lines.mapped('product_uom_qty')))
def get_promo_code_error(self, delete=True):
error = request.session.get('error_promo_code')
if error and delete:
request.session.pop('error_promo_code')
return error
def get_promo_code_success_message(self, delete=True):
if not request.session.get('successful_code'):
return False
code = request.session.get('successful_code')
if delete:
request.session.pop('successful_code')
return code
def _cart_update(self, product_id, line_id=None, add_qty=0, set_qty=0, **kwargs):
line = self.order_line.filtered(lambda sol: sol.product_id.id == product_id)[:1]
reward_id = line.reward_id
if set_qty == 0 and line.coupon_id and reward_id and reward_id.reward_type == 'discount':
# Force the deletion of the line even if it's a temporary record created by new()
line_id = line.id
res = super()._cart_update(
product_id, line_id=line_id, add_qty=add_qty, set_qty=set_qty, **kwargs
)
self._update_programs_and_rewards()
self._auto_apply_rewards()
return res
def _get_free_shipping_lines(self):
self.ensure_one()
return self.order_line.filtered(lambda l: l.reward_id.reward_type == 'shipping')
def _allow_nominative_programs(self):
if not request or not hasattr(request, 'website'):
return super()._allow_nominative_programs()
return not request.website.is_public_user() and super()._allow_nominative_programs()
@api.autovacuum
def _gc_abandoned_coupons(self, *args, **kwargs):
"""Remove coupons from abandonned ecommerce order."""
ICP = self.env['ir.config_parameter']
validity = ICP.get_param('website_sale_coupon.abandonned_coupon_validity', 4)
validity = fields.Datetime.to_string(fields.datetime.now() - timedelta(days=int(validity)))
so_to_reset = self.env['sale.order'].search([
('state', '=', 'draft'),
('write_date', '<', validity),
('website_id', '!=', False),
('applied_coupon_ids', '!=', False),
])
so_to_reset.applied_coupon_ids = False
for so in so_to_reset:
so._update_programs_and_rewards()
def _get_website_sale_extra_values(self):
promo_code_success = self.get_promo_code_success_message(delete=False)
promo_code_error = self.get_promo_code_error(delete=False)
return {
'promo_code_success': promo_code_success,
'promo_code_error': promo_code_error,
}
def _cart_find_product_line(self, product_id, line_id=None, **kwargs):
""" Override to filter out reward lines from the cart lines.
These are handled by the _update_programs_and_rewards and _auto_apply_rewards methods.
"""
lines = super()._cart_find_product_line(product_id, line_id, **kwargs)
lines = lines.filtered(lambda l: not l.is_reward_line) if not line_id else lines
return lines

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import models
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
def _show_in_cart(self):
# Hide discount lines from website_order_line, see `order._compute_website_order_line`
return self.reward_id.reward_type != 'discount' and super()._show_in_cart()
def unlink(self):
if self.env.context.get('website_sale_loyalty_delete', False):
disabled_rewards_per_order = defaultdict(lambda: self.env['loyalty.reward'])
for line in self:
if line.reward_id:
disabled_rewards_per_order[line.order_id] |= line.reward_id
for order, rewards in disabled_rewards_per_order.items():
order.disabled_auto_rewards += rewards
return super().unlink()