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 -*-
import json
from odoo import models, fields, api
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.'
)
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')
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:
if reward.reward_type == 'product' and reward.reward_product_applicability == 'cheapest':
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)
]).ids
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')
reward.multi_product = False
reward.reward_product_ids = products
products |= cat_products
reward.reward_product_ids_json = json.dumps(products.ids)
else:
reward.reward_product_ids_json = json.dumps([])
@api.model
def _load_pos_data_fields(self, config):
@ -42,6 +74,8 @@ class LoyaltyReward(models.Model):
fields.append('reward_product_applicability')
if 'reward_product_category_id' not in fields:
fields.append('reward_product_category_id')
if 'reward_product_ids_json' not in fields:
fields.append('reward_product_ids_json')
return fields
@api.model
@ -61,3 +95,4 @@ class LoyaltyReward(models.Model):
reward_product_tag_domain,
]),
])

View File

@ -17,9 +17,21 @@ class SaleOrder(models.Model):
candidate_lines = self.order_line.filtered(
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(
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:
return None
@ -36,8 +48,20 @@ class SaleOrder(models.Model):
product = self._get_cheapest_matching_product(reward)
if not product:
# Fallback if no eligible product in cart
if reward.reward_product_ids:
product = reward.reward_product_ids[:1]
allowed_products = reward.reward_product_id
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:
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

View File

@ -5,6 +5,26 @@ import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
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).
*/
@ -13,7 +33,7 @@ patch(PosOrder.prototype, {
let cheapestProduct = null;
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 || []) {
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.).
*/
_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 => {
if (line.is_reward_line || line.qty <= 0 || line.price_unit <= 0) return false;