feat: expand loyalty reward eligibility to tags and categories with optimized JSON-based product resolution

This commit is contained in:
Suherdy Yacob 2026-05-28 16:27:46 +07:00
parent 6d5ebdd621
commit e8e5e8d70a
3 changed files with 90 additions and 11 deletions

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import json
from odoo import models, fields, api from odoo import models, fields, api
from odoo.fields import Domain from odoo.fields import Domain
@ -16,9 +17,38 @@ class LoyaltyReward(models.Model):
help='If specified, only products under this category (including subcategories) will be considered for the cheapest product reward.' help='If specified, only products under this category (including subcategories) will be considered for the cheapest product reward.'
) )
reward_product_ids_json = fields.Char(
compute='_compute_reward_product_ids_json',
string='Reward Product IDs JSON'
)
@api.depends('reward_product_id', 'reward_product_tag_id', 'reward_type', 'reward_product_applicability', 'reward_product_category_id') @api.depends('reward_product_id', 'reward_product_tag_id', 'reward_type', 'reward_product_applicability', 'reward_product_category_id')
def _compute_multi_product(self): def _compute_multi_product(self):
super()._compute_multi_product() # Override completely (no super()) to avoid recursive field invalidation.
# In Odoo 19, reassigning a computed field from within its own compute method
# (after calling super()) triggers cache invalidation and re-computation,
# causing a RecursionError via WeakSet.__iter__ during environment cleanup.
for reward in self:
tag_products = reward.reward_product_tag_id.product_ids.filtered(
lambda product: product.type != 'combo'
)
if reward.reward_type == 'product' and reward.reward_product_applicability == 'cheapest':
# For 'cheapest' applicability: include direct product + tag products,
# but not category products (to avoid large sets and reference cycles).
products = reward.reward_product_id + tag_products
reward.multi_product = False
reward.reward_product_ids = products
else:
# Default behaviour (mirrors the standard Odoo computation)
products = reward.reward_product_id + tag_products
reward.multi_product = reward.reward_type == 'product' and len(products) > 1
reward.reward_product_ids = (
products if reward.reward_type == 'product'
else self.env['product.product']
)
@api.depends('reward_product_id', 'reward_product_tag_id', 'reward_type', 'reward_product_applicability', 'reward_product_category_id')
def _compute_reward_product_ids_json(self):
for reward in self: for reward in self:
if reward.reward_type == 'product' and reward.reward_product_applicability == 'cheapest': if reward.reward_type == 'product' and reward.reward_product_applicability == 'cheapest':
products = reward.reward_product_id + reward.reward_product_tag_id.product_ids.filtered( products = reward.reward_product_id + reward.reward_product_tag_id.product_ids.filtered(
@ -29,11 +59,13 @@ class LoyaltyReward(models.Model):
('id', 'child_of', reward.reward_product_category_id.ids) ('id', 'child_of', reward.reward_product_category_id.ids)
]).ids ]).ids
cat_products = self.env['product.product'].search([ cat_products = self.env['product.product'].search([
('categ_id', 'in', category_ids) ('categ_id', 'in', category_ids),
('type', '!=', 'combo')
]) ])
products |= cat_products.filtered(lambda product: product.type != 'combo') products |= cat_products
reward.multi_product = False reward.reward_product_ids_json = json.dumps(products.ids)
reward.reward_product_ids = products else:
reward.reward_product_ids_json = json.dumps([])
@api.model @api.model
def _load_pos_data_fields(self, config): def _load_pos_data_fields(self, config):
@ -42,6 +74,8 @@ class LoyaltyReward(models.Model):
fields.append('reward_product_applicability') fields.append('reward_product_applicability')
if 'reward_product_category_id' not in fields: if 'reward_product_category_id' not in fields:
fields.append('reward_product_category_id') fields.append('reward_product_category_id')
if 'reward_product_ids_json' not in fields:
fields.append('reward_product_ids_json')
return fields return fields
@api.model @api.model
@ -61,3 +95,4 @@ class LoyaltyReward(models.Model):
reward_product_tag_domain, reward_product_tag_domain,
]), ]),
]) ])

View File

@ -17,9 +17,21 @@ class SaleOrder(models.Model):
candidate_lines = self.order_line.filtered( candidate_lines = self.order_line.filtered(
lambda l: not l.is_reward_line and l.product_uom_qty > 0 and l.price_unit > 0 lambda l: not l.is_reward_line and l.product_uom_qty > 0 and l.price_unit > 0
) )
if reward.reward_product_ids: if reward.reward_product_id or reward.reward_product_tag_id or reward.reward_product_category_id:
allowed_products = reward.reward_product_id
if reward.reward_product_tag_id:
allowed_products |= reward.reward_product_tag_id.product_ids
category_ids = []
if reward.reward_product_category_id:
category_ids = self.env['product.category'].search([
('id', 'child_of', reward.reward_product_category_id.ids)
]).ids
candidate_lines = candidate_lines.filtered( candidate_lines = candidate_lines.filtered(
lambda l: l.product_id in reward.reward_product_ids lambda l: l.product_id in allowed_products or (
category_ids and l.product_id.categ_id.id in category_ids
)
) )
if not candidate_lines: if not candidate_lines:
return None return None
@ -36,8 +48,20 @@ class SaleOrder(models.Model):
product = self._get_cheapest_matching_product(reward) product = self._get_cheapest_matching_product(reward)
if not product: if not product:
# Fallback if no eligible product in cart # Fallback if no eligible product in cart
if reward.reward_product_ids: allowed_products = reward.reward_product_id
product = reward.reward_product_ids[:1] if reward.reward_product_tag_id:
allowed_products |= reward.reward_product_tag_id.product_ids
if reward.reward_product_category_id:
category_ids = self.env['product.category'].search([
('id', 'child_of', reward.reward_product_category_id.ids)
]).ids
allowed_products |= self.env['product.product'].search([
('categ_id', 'in', category_ids),
('type', '!=', 'combo')
], limit=1)
if allowed_products:
product = allowed_products[:1]
else: else:
first_line = self.order_line.filtered(lambda l: not l.is_reward_line and l.product_uom_qty > 0)[:1] first_line = self.order_line.filtered(lambda l: not l.is_reward_line and l.product_uom_qty > 0)[:1]
product = first_line.product_id product = first_line.product_id

View File

@ -5,6 +5,26 @@ import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation"; import { _t } from "@web/core/l10n/translation";
patch(PosOrder.prototype, { patch(PosOrder.prototype, {
/**
* Helper to resolve allowed product IDs for a reward, including from reward_product_ids_json.
*/
_getRewardProductIds(reward) {
let rewardProductIds = new Set();
if (reward?.reward_product_applicability === 'cheapest') {
if (reward.reward_product_ids_json) {
try {
const ids = JSON.parse(reward.reward_product_ids_json);
rewardProductIds = new Set(ids);
} catch (e) {
console.error("Failed to parse reward_product_ids_json", e);
}
}
} else {
rewardProductIds = new Set((reward?.reward_product_ids || []).map(p => p.id));
}
return rewardProductIds;
},
/** /**
* Returns the single cheapest product in the order (used for qty/availability checks). * Returns the single cheapest product in the order (used for qty/availability checks).
*/ */
@ -13,7 +33,7 @@ patch(PosOrder.prototype, {
let cheapestProduct = null; let cheapestProduct = null;
let lowestPrice = Infinity; let lowestPrice = Infinity;
const rewardProductIds = new Set((reward?.reward_product_ids || []).map(p => p.id)); const rewardProductIds = this._getRewardProductIds(reward);
for (const line of orderLines || []) { for (const line of orderLines || []) {
if (line.is_reward_line || line.qty <= 0 || line.price_unit <= 0) { if (line.is_reward_line || line.qty <= 0 || line.price_unit <= 0) {
@ -41,7 +61,7 @@ patch(PosOrder.prototype, {
* Used to build multiple reward lines (1st cheapest free, 2nd cheapest free, etc.). * Used to build multiple reward lines (1st cheapest free, 2nd cheapest free, etc.).
*/ */
_getCheapestProductsInOrder(reward, n) { _getCheapestProductsInOrder(reward, n) {
const rewardProductIds = new Set((reward?.reward_product_ids || []).map(p => p.id)); const rewardProductIds = this._getRewardProductIds(reward);
const candidateLines = this.getOrderlines().filter(line => { const candidateLines = this.getOrderlines().filter(line => {
if (line.is_reward_line || line.qty <= 0 || line.price_unit <= 0) return false; if (line.is_reward_line || line.qty <= 0 || line.price_unit <= 0) return false;