feat: expand loyalty reward eligibility to tags and categories with optimized JSON-based product resolution
This commit is contained in:
parent
6d5ebdd621
commit
e8e5e8d70a
@ -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,
|
||||
]),
|
||||
])
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user