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 -*-
|
# -*- 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,
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user