[FIX] pos_loyalty: reward button highlight, free product, etc

- Properly compute the free product quantity.
  - Free product that generate points not based on quantity
  - Same free product from different rewards
- Proper timing of the reward button highlight. If there are products
  that are potentially free product when added, then the reward button
  is highlighted.
- Don't reorder free product reward lines. Before, if there are multiple
  free product rewards, the lines are reordered at each ui action.
- Loyalty points are computed properly, at least when free products are
  involved.
- Introduct some tests to protect the fixes.

closes odoo/odoo#92809

X-original-commit: 3e954d26b7e1e5c41c4f5372010f0573c9adcfe9
Signed-off-by: Masereel Pierre <pim@odoo.com>
This commit is contained in:
Joseph Caburnay
2022-06-07 10:16:22 +02:00
parent f3f750b0f7
commit 75d14c2282
9 changed files with 760 additions and 154 deletions
@@ -140,6 +140,9 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
return { draftPackLotLines, quantity: weight, description, price_extra };
}
async _addProduct(product, options) {
this.currentOrder.add_product(product, options);
}
async _clickProduct(event) {
if (!this.currentOrder) {
this.env.pos.add_new_order();
@@ -149,7 +152,7 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
// Do not add product if options is undefined.
if (!options) return;
// Add the product after having the extra information.
this.currentOrder.add_product(product, options);
await this._addProduct(product, options);
NumberBuffer.reset();
}
_setNumpadMode(event) {
@@ -12,10 +12,31 @@ export class RewardButton extends PosComponent {
useListener('click', this.onClick);
}
hasClaimableRewards() {
/**
* If rewards are the same, prioritize the one from freeProductRewards.
* Make sure that the reward is claimable first.
*/
_mergeFreeProductRewards(freeProductRewards, potentialFreeProductRewards) {
const result = []
for (const reward of potentialFreeProductRewards) {
if (!freeProductRewards.find(item => item.reward.id === reward.reward.id)) {
result.push(reward);
}
}
return freeProductRewards.concat(result);
}
_getPotentialRewards() {
const order = this.env.pos.get_order();
const rewards = order ? order.getClaimableRewards() : [];
return rewards.length > 0;
const discountRewards = rewards.filter(({ reward }) => reward.reward_type == 'discount');
const freeProductRewards = rewards.filter(({ reward }) => reward.reward_type == 'product');
const potentialFreeProductRewards = order.getPotentialFreeProductRewards();
return discountRewards.concat(this._mergeFreeProductRewards(freeProductRewards, potentialFreeProductRewards));
}
hasClaimableRewards() {
return this._getPotentialRewards().length > 0;
}
/**
@@ -24,7 +45,10 @@ export class RewardButton extends PosComponent {
* @param {Object} reward
* @param {Integer} coupon_id
*/
async _applyReward(reward, coupon_id) {
async _applyReward(reward, coupon_id, potentialQty) {
const order = this.env.pos.get_order();
order.disabledRewards.delete(reward.id);
const args = {};
if (reward.reward_type === 'product' && reward.multi_product) {
const productsList = reward.reward_product_ids.map((product_id) => ({
@@ -41,18 +65,29 @@ export class RewardButton extends PosComponent {
}
args['product'] = selectedProduct;
}
const result = this.env.pos.get_order()._applyReward(reward, coupon_id, args);
if (result !== true) {
// Returned an error
Gui.showNotification(result);
if (
(reward.reward_type == 'product' && reward.program_id.applies_on !== 'both') ||
(reward.program_id.applies_on == 'both' && potentialQty)
) {
// Delegate the addition of product and reward update to the `click-product` event.
this.trigger(
'click-product',
this.env.pos.db.get_product_by_id(args['product'] || reward.reward_product_ids[0])
);
return true;
} else {
const result = order._applyReward(reward, coupon_id, args);
if (result !== true) {
// Returned an error
Gui.showNotification(result);
}
order._updateRewards();
return result;
}
this.env.pos.get_order()._updateRewards();
return result;
}
async onClick() {
const order = this.env.pos.get_order();
const rewards = order.getClaimableRewards();
const rewards = this._getPotentialRewards();
if (rewards.length === 0) {
await this.showPopup('ErrorPopup', {
title: this.env._t('No rewards available.'),
@@ -60,7 +95,7 @@ export class RewardButton extends PosComponent {
});
return false;
} else if (rewards.length === 1) {
return this._applyReward(rewards[0].reward, rewards[0].coupon_id);
return this._applyReward(rewards[0].reward, rewards[0].coupon_id, rewards[0].potentialQty);
} else {
const rewardsList = rewards.map((reward) => ({
id: reward.reward.id,
@@ -72,7 +107,7 @@ export class RewardButton extends PosComponent {
list: rewardsList,
});
if (confirmed) {
return this._applyReward(selectedReward.reward, selectedReward.coupon_id);
return this._applyReward(selectedReward.reward, selectedReward.coupon_id, selectedReward.potentialQty);
}
}
return false;
+236 -135
View File
@@ -36,6 +36,45 @@ export class PosLoyaltyCard {
}
}
/**
* Calculate the number of free items based on the given number
* of items `number_items` and the rule: buy `n` take `m`.
*
* e.g.
*```
* rule: buy 2 take 1 rule: buy 2 take 3
* +------------+--------+--------+ +------------+--------+--------+
* |number_items| charged| free| |number_items| charged| free|
* +------------+--------+--------+ +------------+--------+--------+
* | 1| 1| 0| | 1| 1| 0|
* | 2| 2| 0| | 2| 2| 0|
* | 3| 2| 1| | 3| 2| 1|
* | 4| 3| 1| | 4| 2| 2|
* | 5| 4| 1| | 5| 2| 3|
* | 6| 4| 2| | 6| 3| 3|
* | 7| 5| 2| | 7| 4| 3|
* | 8| 6| 2| | 8| 4| 4|
* | 9| 6| 3| | 9| 4| 5|
* | 10| 7| 3| | 10| 4| 6|
* +------------+--------+--------+ +------------+--------+--------+
* ```
*
* @param {number} numberItems number of items
* @param {number} n items to buy
* @param {number} m item for free
* @returns {number} number of free items
*/
function computeFreeQuantity(numberItems, n, m) {
let factor = Math.trunc(numberItems / (n + m));
let free = factor * m;
let charged = numberItems - free;
// adjust the calculated free quantities
let x = (factor + 1) * n;
let y = x + (factor + 1) * m;
let adjustment = x <= charged && charged < y ? charged - x : 0;
return Math.floor(free + adjustment);
}
const PosLoyaltyGlobalState = (PosGlobalState) => class PosLoyaltyGlobalState extends PosGlobalState {
//@override
async _processData(loadedData) {
@@ -198,10 +237,13 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
constructor() {
super(...arguments);
this._initializePrograms({});
// Always start with invalid coupons so that coupon for this
// order is properly assigned. @see _checkMissingCoupons
this.invalidCoupons = true;
}
export_as_JSON() {
const json = super.export_as_JSON(...arguments);
json.disabledRewards = this.disabledRewards;
json.disabledRewards = [...this.disabledRewards];
json.codeActivatedProgramRules = this.codeActivatedProgramRules;
json.codeActivatedCoupons = this.codeActivatedCoupons;
json.couponPointChanges = this.couponPointChanges;
@@ -224,10 +266,9 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
}
super.init_from_JSON(...arguments);
delete this.oldCouponMapping;
this.disabledRewards = json.disabledRewards;
this.disabledRewards = new Set(json.disabledRewards);
this.codeActivatedProgramRules = json.codeActivatedProgramRules;
this.codeActivatedCoupons = json.codeActivatedCoupons;
this.invalidCoupons = true;
}
/**
* We need to update the rewards upon changing the partner as it may impact the points available
@@ -237,7 +278,12 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
*/
set_partner(partner) {
const oldPartner = this.get_partner();
super.set_partner(...arguments);
super.set_partner(partner);
if (oldPartner && oldPartner.loyalty_card_id && oldPartner.loyalty_card_id in this.couponPointChanges) {
// Remove the coupon from the tracked couponPointChanges, and it will be replaced
// with the correct one in `_updatePrograms`.
delete this.couponPointChanges[oldPartner.loyalty_card_id];
}
if (oldPartner !== this.get_partner()) {
this._updateRewards();
}
@@ -354,7 +400,7 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
// When deleting a reward line, a popup will be displayed if the reward was automatic,
// if confirmed the reward is added to this list and will not be claimed automatically again.
if (!this.disabledRewards) {
this.disabledRewards = [];
this.disabledRewards = new Set();
}
// List of programs that require a code that are activated.
if (!this.codeActivatedProgramRules) {
@@ -371,7 +417,7 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
}
}
_resetPrograms() {
this.disabledRewards = [];
this.disabledRewards = new Set();
this.codeActivatedProgramRules = [];
this.codeActivatedCoupons = [];
this.couponPointChanges = {};
@@ -389,7 +435,7 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
let changed = false;
for (const {coupon_id, reward} of claimableRewards) {
if (reward.program_id.rewards.length === 1 && !reward.program_id.is_nominative &&
(reward.reward_type !== 'product' || !reward.multi_product)) {
(reward.reward_type !== 'product' || (reward.reward_type == 'product' && !reward.multi_product))) {
this._applyReward(reward, coupon_id);
changed = true;
}
@@ -402,6 +448,9 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
})}).catch(() => {/* catch the reject of dp when calling `add` to avoid unhandledrejection */});
}
async _updateLoyaltyPrograms() {
if (this.partner && !(this.partner.loyalty_card_id in this.pos.couponCache)) {
this.pos.couponCache[this.partner.loyalty_card_id] = new PosLoyaltyCard(null, this.partner.loyalty_card_id, this.pos.config.loyalty_program_id[0], this.partner.id, this.partner.loyalty_points);
}
await this._checkMissingCoupons();
await this._updatePrograms();
}
@@ -427,7 +476,7 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
await this.pos.fetchCoupons([['id', 'in', couponsToFetch]], couponsToFetch.length);
// Remove coupons that could not be loaded from the db
this.codeActivatedCoupons = this.codeActivatedCoupons.filter((coupon) => this.pos.couponCache[coupon.id]);
this.couponPointChanges = Object.fromEntries(Object.entries(this.couponPointChanges).filter((k, pe) => this.pos.couponCache[pe.coupon_id]));
this.couponPointChanges = Object.fromEntries(Object.entries(this.couponPointChanges).filter(([k, pe]) => this.pos.couponCache[pe.coupon_id]));
}
});
}
@@ -442,7 +491,8 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
if (!rewardLines.length) {
return;
}
const claimedRewards = [];
const productRewards = []
const otherRewards = [];
const paymentRewards = []; // Gift card and ewallet rewards are considered payments and must stay at the end
for (const line of rewardLines) {
const claimedReward = {
@@ -455,14 +505,13 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
if (claimedReward.reward.program_id.program_type === 'gift_card' || claimedReward.reward.program_id.program_type === 'ewallet') {
paymentRewards.push(claimedReward);
} else if (claimedReward.reward.reward_type === 'product') {
claimedRewards.unshift(claimedReward);
productRewards.push(claimedReward);
} else {
claimedRewards.push(claimedReward);
otherRewards.push(claimedReward);
}
this.orderlines.remove(line);
}
claimedRewards.push(...paymentRewards);
for (const claimedReward of claimedRewards) {
for (const claimedReward of productRewards.concat(otherRewards).concat(paymentRewards)) {
// For existing coupons check that they are still claimed, they can exist in either `couponPointChanges` or `codeActivatedCoupons`
if (!this.codeActivatedCoupons.find((coupon) => coupon.id === claimedReward.coupon_id) &&
!this.couponPointChanges[claimedReward.coupon_id]) {
@@ -550,7 +599,7 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
if (!couponId) {
couponId = pe.coupon_id;
}
won += pe.points;
won += pe.points - this._getPointsCorrection(this.pos.program_by_id[pe.program_id]);
break;
}
}
@@ -574,6 +623,33 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
name: name,
}
}
/**
* The points in the couponPointChanges for free product reward is not correct.
* It doesn't take into account the points from the `free` product. Use this method
* to compute the necessary correction.
* @param {*} program
* @returns {number}
*/
_getPointsCorrection(program) {
const rewardLines = this.orderlines.filter(line => line.is_reward_line);
let res = 0;
for (const rule of program.rules) {
for (const line of rewardLines) {
const reward = this.pos.reward_by_id[line.reward_id]
if (reward.reward_type !== 'product') {
continue
}
if (rule.reward_point_mode === 'order') {
res += rule.reward_point_amount;
} else if (rule.reward_point_mode === 'money') {
res -= round_precision(rule.reward_point_amount * line.get_price_with_tax(), 0.01);
} else if (rule.reward_point_mode === 'unit') {
res += rule.reward_point_amount * line.get_quantity();
}
}
}
return res;
}
/**
* @returns {number} The points that are left for the given coupon for this order.
*/
@@ -745,6 +821,37 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
_getGlobalDiscountLines() {
return this.get_orderlines().filter((line) => line.reward_id && this.pos.reward_by_id[line.reward_id].is_global_discount);
}
/**
* Returns the number of product items in the order based on the given rule.
* @param {*} rule
*/
_computeNItems(rule) {
return this._get_regular_order_lines().reduce((nItems, line) => {
let increment = 0;
if (rule.any_product || rule.valid_product_ids.has(line.product.id)) {
increment = line.get_quantity();
}
return nItems + increment;
}, 0);
}
/**
* Checks whether this order is allowed to generate rewards
* from the given coupon program.
* @param {*} couponProgram
*/
_canGenerateRewards(couponProgram, orderTotalWithTax, orderTotalWithoutTax) {
for (const rule of couponProgram.rules) {
const amountToCompare = rule.minimum_amount_tax_mode == 'incl' ? orderTotalWithTax : orderTotalWithoutTax
if (rule.minimum_amount > amountToCompare) {
return false;
}
const nItems = this._computeNItems(rule);
if (rule.minimum_qty > nItems) {
return false;
}
}
return true;
}
/**
* @param {Integer} coupon_id (optional) Coupon id
* @param {Integer} program_id (optional) Program id
@@ -763,22 +870,31 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
};
}));
const result = [];
const totalIsZero = this.get_total_with_tax() === 0;
const totalWithTax = this.get_total_with_tax();
const totalWithoutTax = this.get_total_without_tax();
const totalIsZero = totalWithTax === 0;
const globalDiscountLines = this._getGlobalDiscountLines();
const globalDiscountPercent = globalDiscountLines.length ?
this.pos.reward_by_id[globalDiscountLines[0].reward_id].discount : 0;
for (const couponProgram of allCouponPrograms) {
const program = this.pos.program_by_id[couponProgram.program_id];
if (program.trigger == 'with_code') {
// For coupon programs, the rules become conditions.
// Points to purchase rewards will only come from the scanned coupon.
if (!this._canGenerateRewards(program, totalWithTax, totalWithoutTax)) {
continue;
};
}
if ((coupon_id && couponProgram.coupon_id !== coupon_id) ||
(program_id && couponProgram.program_id !== program_id)) {
continue;
}
const program = this.pos.program_by_id[couponProgram.program_id];
const points = this._getRealCouponPoints(couponProgram.coupon_id);
for (const reward of program.rewards) {
if (points < reward.required_points) {
continue;
}
if (auto && this.disabledRewards.includes(reward.id)) {
if (auto && this.disabledRewards.has(reward.id)) {
continue;
}
// Try to filter out rewards that will not be claimable anyway.
@@ -790,8 +906,8 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
}
if (reward.reward_type === 'product' && !reward.multi_product) {
const product = this.pos.db.get_product_by_id(reward.reward_product_ids[0]);
const availableQty = this._computeAvailableFreeProduct(reward, couponProgram.coupon_id, product);
if (availableQty <= 0) {
const unclaimedQty = this._computeUnclaimedFreeProductQty(reward, couponProgram.coupon_id, product, points);
if (unclaimedQty <= 0) {
continue;
}
}
@@ -803,6 +919,50 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
}
return result;
}
/**
* Returns the reward such that when its reward product is added
* in the order, it will be added as free. That is, when added,
* it comes with the corresponding reward product line.
*/
getPotentialFreeProductRewards() {
const allCouponPrograms = Object.values(this.couponPointChanges).map((pe) => {
return {
program_id: pe.program_id,
coupon_id: pe.coupon_id,
};
}).concat(this.codeActivatedCoupons.map((coupon) => {
return {
program_id: coupon.program_id,
coupon_id: coupon.id,
};
}));
const result = [];
for (const couponProgram of allCouponPrograms) {
const program = this.pos.program_by_id[couponProgram.program_id];
const points = this._getRealCouponPoints(couponProgram.coupon_id);
const hasLine = this.orderlines.filter(line => !line.is_reward_line).length > 0;
for (const reward of program.rewards.filter(reward => reward.reward_type == 'product')) {
if (points < reward.required_points) {
continue;
}
// Loyalty program (applies_on == 'both') should needs an orderline before it can apply a reward.
const considerTheReward = program.applies_on !== 'both' || (program.applies_on == 'both' && hasLine);
if (reward.reward_type === 'product' && considerTheReward) {
const product = this.pos.db.get_product_by_id(reward.reward_product_ids[0]);
const potentialQty = this._computePotentialFreeProductQty(reward, product, points);
if (potentialQty <= 0) {
continue;
}
result.push({
coupon_id: couponProgram.coupon_id,
reward: reward,
potentialQty
});
}
}
}
return result;
}
/**
* Applies a reward to the order, `_updateRewards` is expected to be called right after.
*
@@ -1100,6 +1260,12 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
}
return result;
}
_isRewardProductPartOfRules(reward, product) {
return (
reward.program_id.rules.filter((rule) => rule.any_product || rule.valid_product_ids.has(product.id))
.length > 0
);
}
/**
* Tries to compute how many free product can be given out for the given product.
* Contrary to sale_loyalty, the product must be in the order lines in order to give it out
@@ -1112,127 +1278,62 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
* @param {Product} product
* @returns {Integer} Available quantity to be given as reward for the given product
*/
_computeAvailableFreeProduct(reward, coupon_id, product) {
// Might not be perfect but good enough
let productPriceWithTax = null;
const orderlines = this.get_orderlines();
const program = reward.program_id;
let freeClaimed = 0;
_computeUnclaimedFreeProductQty(reward, coupon_id, product, remainingPoints) {
let claimed = 0;
let available = 0;
for (const line of orderlines) {
let shouldCorrectRemainingPoints = false;
for (const line of this.get_orderlines()) {
if (line.get_product().id === product.id) {
if (productPriceWithTax === null) {
productPriceWithTax = line.get_price_with_tax() / line.get_quantity();
}
available += line.get_quantity();
} else if (line.reward_product_id === product.id) {
freeClaimed += line.get_quantity();
if (line.reward_id == reward.id ) {
claimed += line.get_quantity();
} else {
shouldCorrectRemainingPoints = true;
}
}
}
// This only applies to programs that apply on current and both orders and only if points are being counted for that coupon.
const pointChangeEntry = this.couponPointChanges[coupon_id];
if (available && (pointChangeEntry && program.applies_on === 'current' || program.applies_on === 'both')) {
/**
* We have to count the points again in this case because we have to know how much points will be lost
* after we apply the free product, take for example a 2+1 free program, you could have 1 points per unit paid
* with the reward being the free product that takes 1 point. At the time of counting the points
* if you have 2 units in your list you would technically be eligible for the reward since you have enough points.
* However because claiming the unit would result in a loss of 2 points, you would lose our eligibility.
* This seams like a small problem but we have to take note that we may also get points from other products and as such they
* a free product might not impact the points at all.
* Example program:
* 2 rules:
* 1: 1 point per unit paid on product A.
* 2 10 points per unit paid on product B.
* 1 reward:
* 1: for 2 point, 1 free product A.
* This means that if we have a product B we are eligible for 5 free product A.
* But also that we need at least 6 product A before rule 1 starts counting points again.
* Since reward lines at this point may or may not be present we have to remove the points we compute in this function.
*/
let points = this._getRealCouponPoints(coupon_id);
let totalTaxed = this.get_total_with_tax();
let totalUntaxed = this.get_total_without_tax();
orderlines.forEach((line) => {
if (!line.reward_id) {
return;
}
const lineReward = this.pos.reward_by_id[line.reward_id];
if (lineReward.reward_type !== 'discount') {
return;
}
const rewardProgram = lineReward.program_id;
if (rewardProgram.id === program.id || rewardProgram.trigger === 'auto') {
totalTaxed -= line.get_price_with_tax();
totalUntaxed -= line.get_price_without_tax();
}
});
// Compute how much one unit costs in terms of points
const ruleData = [];
for (const rule of program.rules) {
if (!rule.any_product && !rule.valid_product_ids.has(product.id)) {
continue;
}
const amountToCheck = (rule.minimum_amount_tax_mode === 'incl' ? totalTaxed : totalUntaxed);
if (rule.minimum_amount > amountToCheck) {
continue;
}
let totalProductQty = 0;
let orderedProductPaid = 0;
orderlines.forEach((line) => {
if ((!line.reward_product_id && (rule.any_product || rule.valid_product_ids.has(line.get_product().id))) ||
(line.reward_product_id && (rule.any_product || rule.valid_product_ids.has(line.reward_product_id)))) {
// We only count reward products from the same program to avoid unwanted feedback loops
if (line.reward_product_id) {
const reward = this.pos.reward_by_id[line.reward_id];
if (program.id !== reward.program_id.id) {
return;
}
let freeQty;
if (reward.program_id.trigger == 'auto') {
if (this._isRewardProductPartOfRules(reward, product)) {
// OPTIMIZATION: Pre-calculate the factors for each reward-product combination during the loading.
// For points not based on quantity, need to normalize the points to compute free quantity.
const factor = reward.program_id.rules.reduce((result, rule) => {
if (rule.any_product || rule.valid_product_ids.has(product.id)) {
if (rule.reward_point_mode === 'order') {
result += rule.reward_point_amount;
} else if (rule.reward_point_mode === 'money') {
result += round_precision(rule.reward_point_amount * product.lst_price, 0.01);
} else if (rule.reward_point_mode === 'unit') {
result += rule.reward_point_amount;
}
const lineQty = (line.reward_product_id ? -line.get_quantity() : line.get_quantity());
totalProductQty += lineQty;
orderedProductPaid += line.get_price_with_tax();
}
});
const thisRuleChanges = {};
thisRuleChanges.maxUnits = product.price ? Math.floor((amountToCheck - rule.minimum_amount) / product.price) : Infinity;
thisRuleChanges.maxUnits = Math.min(available - rule.minimum_qty, thisRuleChanges.maxUnits);
if (rule.reward_point_mode === 'unit') {
thisRuleChanges.pointsPerUnit = rule.reward_point_amount;
thisRuleChanges.pointsAdded = rule.reward_point_amount * totalProductQty;
} else if (rule.reward_point_mode === 'money') {
thisRuleChanges.pointsPerUnit = rule.reward_point_amount * productPriceWithTax;
thisRuleChanges.pointsAdded = round_precision(rule.reward_point_amount * orderedProductPaid, 0.01);
} else if (rule.reward_point_mode === 'order') {
thisRuleChanges.pointsPerUnit = 0;
thisRuleChanges.pointsAdded = rule.reward_point_amount;
}
ruleData.push(thisRuleChanges);
return result;
}, 0)
const correction = shouldCorrectRemainingPoints ? this._getPointsCorrection(reward.program_id) : 0
freeQty = computeFreeQuantity((remainingPoints - correction) / factor, reward.required_points / factor, reward.reward_product_qty);
} else {
freeQty = Math.floor((remainingPoints / reward.required_points) * reward.reward_product_qty);
}
// We actually have to try out all possible amounts
const maximumQty = Math.min(available, Math.floor(points / reward.required_points) * reward.reward_product_qty);
let totalLostPerUnit = ruleData.reduce((sum, data) => sum + data.pointsPerUnit, 0);
let lostPoints = 0;
let units;
for (units = 1; units <= maximumQty; units++) {
let localLostPoints = lostPoints + totalLostPerUnit;
for (const data of ruleData) {
if (units === (data.maxUnits + 1)) {
localLostPoints -= data.pointsPerUnit * units;
totalLostPerUnit -= data.pointsPerUnit;
localLostPoints += data.pointsAdded;
}
}
const consumedPoints = Math.ceil(units / reward.reward_product_qty) * reward.required_points;
if (points - consumedPoints - localLostPoints < 0) {
units -= 1;
break;
}
lostPoints = localLostPoints;
}
available = Math.max(0, Math.min(units, available) - freeClaimed);
} else if (reward.program_id.trigger == 'with_code') {
freeQty = Math.floor((remainingPoints / reward.required_points) * reward.reward_product_qty);
}
return Math.min(available, freeQty) - claimed;
}
_computePotentialFreeProductQty(reward, product, remainingPoints) {
if (reward.program_id.trigger == 'auto') {
if (this._isRewardProductPartOfRules(reward, product)) {
const line = this.get_orderlines().find(line => line.reward_product_id === product.id);
// Compute the correction points once even if there are multiple reward lines.
// This is because _getPointsCorrection is taking into account all the lines already.
const claimedPoints = line ? this._getPointsCorrection(reward.program_id) : 0;
return Math.floor(((remainingPoints - claimedPoints) / reward.required_points) * reward.reward_product_qty);
} else {
return Math.floor((remainingPoints / reward.required_points) * reward.reward_product_qty);
}
} else if (reward.program_id.trigger == 'with_code') {
return Math.floor((remainingPoints / reward.required_points) * reward.reward_product_qty);
}
return available;
}
/**
* @param {Object} args See `_applyReward`
@@ -1241,19 +1342,19 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
_getRewardLineValuesProduct(args) {
const reward = args['reward'];
const product = this.pos.db.get_product_by_id(args['product'] || reward.reward_product_ids[0]);
const availableQty = this._computeAvailableFreeProduct(reward, args['coupon_id'], product);
if (availableQty <= 0) {
const points = this._getRealCouponPoints(args['coupon_id']);
const unclaimedQty = this._computeUnclaimedFreeProductQty(reward, args['coupon_id'], product, points);
if (unclaimedQty <= 0) {
return _t("There are not enough products in the basket to claim this reward.");
}
const points = this._getRealCouponPoints(args['coupon_id']);
const claimable_count = reward.clear_wallet ? 1 : Math.min(Math.ceil(availableQty / reward.reward_product_qty), Math.floor(points / reward.required_points));
const claimable_count = reward.clear_wallet ? 1 : Math.min(Math.ceil(unclaimedQty / reward.reward_product_qty), Math.floor(points / reward.required_points));
const cost = reward.clear_wallet ? points : claimable_count * reward.required_points;
return [{
product: reward.discount_line_product_id,
price: -product.lst_price,
tax_ids: product.taxes_id,
// In case the reward is the product multiple times, give it as many times as possible
quantity: Math.min(availableQty, reward.reward_product_qty * claimable_count),
quantity: Math.min(unclaimedQty, reward.reward_product_qty * claimable_count),
reward_id: reward.id,
is_reward_line: true,
reward_product_id: product.id,
@@ -84,7 +84,9 @@ export const PosLoyaltyPaymentScreen = (PaymentScreen) =>
const partner = order.get_partner();
let partnerLoyaltyCardId = partner ? partner.loyalty_card_id : 0;
let couponData = Object.values(order.couponPointChanges).reduce((agg, pe) => {
agg[pe.coupon_id] = Object.assign({}, pe);
agg[pe.coupon_id] = Object.assign({}, pe, {
points: pe.points - order._getPointsCorrection(this.env.pos.program_by_id[pe.program_id]),
});
const program = this.env.pos.program_by_id[pe.program_id];
if (program.is_nominative && order.get_partner()) {
agg[pe.coupon_id].partner_id = partner.id;
@@ -13,6 +13,29 @@ export const PosLoyaltyProductScreen = (ProductScreen) =>
});
}
/**
* If the product is a potential reward, also apply the reward.
* @override
*/
async _addProduct(product, options) {
const order = this.env.pos.get_order();
const potentialRewards = order.getPotentialFreeProductRewards();
let rewardsToApply = [];
for (const reward of potentialRewards) {
for (const reward_product_id of reward.reward.reward_product_ids) {
if (reward_product_id == product.id) {
rewardsToApply.push(reward);
}
}
}
await super._addProduct(product, options);
await order._updatePrograms();
if (rewardsToApply.length == 1) {
const reward = rewardsToApply[0];
order._applyReward(reward.reward, reward.coupon_id, { product: product.id });
}
}
_onCouponScan(code) {
this.currentOrder.activateCode(code.base_code);
}
@@ -64,7 +87,7 @@ export const PosLoyaltyProductScreen = (ProductScreen) =>
}
if (!selectedLine) return;
if (selectedLine.is_reward_line && val === 'remove') {
this.currentOrder.disabledRewards.push(selectedLine.reward_id);
this.currentOrder.disabledRewards.add(selectedLine.reward_id);
const coupon = this.env.pos.couponCache[selectedLine.coupon_id];
if (coupon && coupon.id > 0 && this.currentOrder.codeActivatedCoupons.find((c) => c.code === coupon.code)) {
delete this.env.pos.couponCache[selectedLine.coupon_id];
@@ -0,0 +1,142 @@
/** @odoo-module **/
import { PosLoyalty } from 'pos_loyalty.tour.PosCouponTourMethods';
import { ProductScreen } from 'point_of_sale.tour.ProductScreenTourMethods';
import { getSteps, startSteps } from 'point_of_sale.tour.utils';
import Tour from 'web_tour.tour';
startSteps();
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
// Order1: Generates 2 points.
ProductScreen.exec.addOrderline('Whiteboard Pen', '2');
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('Test Partner AAA');
PosLoyalty.check.orderTotalIs('6.40');
PosLoyalty.exec.finalizeOrder('Cash', '10');
// Order2: Consumes points to get free product.
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('Test Partner AAA');
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '1.00');
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '2.00');
// At this point, Test Partner AAA has 4 points.
PosLoyalty.check.isRewardButtonHighlighted(true);
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '3.00');
PosLoyalty.check.hasRewardLine('Free Product - Whiteboard Pen', '-3.20', '1.00');
PosLoyalty.check.isRewardButtonHighlighted(false);
PosLoyalty.check.orderTotalIs('6.40');
PosLoyalty.exec.finalizeOrder('Cash', '10');
// Order3: Generate 4 points.
// - Initially gets free product, but was removed automatically by changing the
// number of items to zero.
// - It's impossible to checked here if the free product reward is really removed
// so we check in the backend the number of orders that consumed the reward.
ProductScreen.exec.addOrderline('Whiteboard Pen', '4');
PosLoyalty.check.orderTotalIs('12.80');
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('Test Partner AAA');
PosLoyalty.check.isRewardButtonHighlighted(true);
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
PosLoyalty.check.hasRewardLine('Free Product - Whiteboard Pen', '-3.20', '1.00');
PosLoyalty.check.isRewardButtonHighlighted(false);
ProductScreen.do.pressNumpad('Backspace');
// At this point, the reward line should have been automatically removed
// because there is not enough points to purchase it. Unfortunately, we
// can't check that here.
PosLoyalty.check.orderTotalIs('0.00');
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '1.00');
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '2.00');
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '3.00');
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
PosLoyalty.check.isRewardButtonHighlighted(false);
ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '4.00');
PosLoyalty.check.isRewardButtonHighlighted(true);
PosLoyalty.check.orderTotalIs('12.80');
PosLoyalty.exec.finalizeOrder('Cash', '20');
Tour.register('PosLoyaltyLoyaltyProgram1', { test: true, url: '/pos/web' }, getSteps());
startSteps();
ProductScreen.do.clickHomeCategory();
// Order1: Immediately set the customer to Test Partner AAA which has 4 points.
// - He has enough points to purchase a free product but since there is still
// no product in the order, reward button should not yet be highlighted.
// - Furthermore, clicking the reward product should not add it as reward product.
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('Test Partner AAA');
// No item in the order, so reward button is off.
PosLoyalty.check.isRewardButtonHighlighted(false);
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
PosLoyalty.check.isRewardButtonHighlighted(true);
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
PosLoyalty.check.hasRewardLine('Free Product - Whiteboard Pen', '-3.20', '1.00');
PosLoyalty.check.isRewardButtonHighlighted(false);
PosLoyalty.check.orderTotalIs('3.20');
PosLoyalty.exec.finalizeOrder('Cash', '10');
// Order2: Generate 4 points for Test Partner CCC.
// - Reference: Order2_CCC
// - But set Test Partner BBB first as the customer.
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('Test Partner BBB');
PosLoyalty.check.isRewardButtonHighlighted(false);
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '1.00');
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '2.00');
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '3.00');
PosLoyalty.check.isRewardButtonHighlighted(false);
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '4.00');
PosLoyalty.check.isRewardButtonHighlighted(true);
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('Test Partner CCC');
PosLoyalty.check.customerIs('Test Partner CCC');
PosLoyalty.check.orderTotalIs('12.80');
PosLoyalty.exec.finalizeOrder('Cash', '20');
// Order3: Generate 3 points for Test Partner BBB.
// - Reference: Order3_BBB
// - But set Test Partner CCC first as the customer.
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('Test Partner CCC');
PosLoyalty.check.isRewardButtonHighlighted(false);
ProductScreen.exec.addOrderline('Whiteboard Pen', '3');
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('Test Partner BBB');
PosLoyalty.check.customerIs('Test Partner BBB');
PosLoyalty.check.orderTotalIs('9.60');
PosLoyalty.exec.finalizeOrder('Cash', '10');
// Order4: Should not have reward because the customer will be removed.
// - Reference: Order4_no_reward
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '1.00');
PosLoyalty.check.isRewardButtonHighlighted(false);
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('Test Partner CCC');
PosLoyalty.check.isRewardButtonHighlighted(true);
PosLoyalty.do.clickRewardButton();
PosLoyalty.check.hasRewardLine('Free Product - Whiteboard Pen', '-3.20', '1.00');
ProductScreen.do.clickPartnerButton();
// This deselects the customer.
PosLoyalty.do.unselectPartner();
PosLoyalty.check.customerIs('Customer');
PosLoyalty.check.orderTotalIs('3.20');
PosLoyalty.exec.finalizeOrder('Cash', '10');
Tour.register('PosLoyaltyLoyaltyProgram2', { test: true, url: '/pos/web' }, getSteps());
@@ -0,0 +1,130 @@
/** @odoo-module **/
import { PosLoyalty } from 'pos_loyalty.tour.PosCouponTourMethods';
import { ProductScreen } from 'point_of_sale.tour.ProductScreenTourMethods';
import { SelectionPopup } from 'point_of_sale.tour.SelectionPopupTourMethods';
import { getSteps, startSteps } from 'point_of_sale.tour.utils';
import Tour from 'web_tour.tour';
startSteps();
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
ProductScreen.exec.addOrderline('Desk Organizer', '2');
// At this point, the free_product program is triggered.
// The reward button should be highlighted.
PosLoyalty.check.isRewardButtonHighlighted(true);
// Since the reward button is highlighted, clicking the reward product should be added as reward.
ProductScreen.do.clickDisplayedProduct('Desk Organizer');
ProductScreen.check.selectedOrderlineHas('Desk Organizer', '3.00');
PosLoyalty.check.hasRewardLine('Free Product - Desk Organizer', '-5.10', '1.00');
// In the succeeding 2 clicks on the product, it is considered as a regular product.
// In the third click, the product will be added as reward.
ProductScreen.do.clickDisplayedProduct('Desk Organizer');
ProductScreen.do.clickDisplayedProduct('Desk Organizer');
PosLoyalty.check.isRewardButtonHighlighted(true);
ProductScreen.do.clickDisplayedProduct('Desk Organizer');
ProductScreen.check.selectedOrderlineHas('Desk Organizer', '6.00');
PosLoyalty.check.hasRewardLine('Free Product - Desk Organizer', '-10.20', '2.00');
ProductScreen.do.clickDisplayedProduct('Desk Organizer');
PosLoyalty.check.isRewardButtonHighlighted(false);
PosLoyalty.check.orderTotalIs('25.50');
// Finalize order that consumed a reward.
PosLoyalty.exec.finalizeOrder('Cash', '30');
ProductScreen.do.clickDisplayedProduct('Desk Organizer');
ProductScreen.check.selectedOrderlineHas('Desk Organizer', '1.00');
ProductScreen.do.clickDisplayedProduct('Desk Organizer');
ProductScreen.check.selectedOrderlineHas('Desk Organizer', '2.00');
ProductScreen.do.clickDisplayedProduct('Desk Organizer');
PosLoyalty.check.hasRewardLine('Free Product - Desk Organizer', '-5.10', '1.00');
ProductScreen.do.pressNumpad('Backspace');
ProductScreen.check.selectedOrderlineHas('Desk Organizer', '0.00');
ProductScreen.do.clickDisplayedProduct('Desk Organizer');
ProductScreen.check.selectedOrderlineHas('Desk Organizer', '1.00');
ProductScreen.do.clickDisplayedProduct('Desk Organizer');
ProductScreen.check.selectedOrderlineHas('Desk Organizer', '2.00');
PosLoyalty.check.isRewardButtonHighlighted(true);
// Finalize order but without the reward.
// This step is important. When syncing the order, no reward should be synced.
PosLoyalty.check.orderTotalIs('10.20');
PosLoyalty.exec.finalizeOrder('Cash', '20');
ProductScreen.exec.addOrderline('Magnetic Board', '2');
PosLoyalty.check.isRewardButtonHighlighted(false);
ProductScreen.do.clickDisplayedProduct('Magnetic Board');
PosLoyalty.check.isRewardButtonHighlighted(true);
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
PosLoyalty.check.isRewardButtonHighlighted(false);
PosLoyalty.check.hasRewardLine('Free Product - Whiteboard Pen', '-3.20', '1.00');
ProductScreen.do.clickOrderline('Magnetic Board', '3.00');
ProductScreen.check.selectedOrderlineHas('Magnetic Board', '3.00');
ProductScreen.do.pressNumpad('6');
ProductScreen.check.selectedOrderlineHas('Magnetic Board', '6.00');
PosLoyalty.check.isRewardButtonHighlighted(true);
PosLoyalty.do.clickRewardButton();
PosLoyalty.check.isRewardButtonHighlighted(false);
PosLoyalty.check.hasRewardLine('Free Product - Whiteboard Pen', '-6.40', '2.00');
// Finalize order that consumed a reward.
PosLoyalty.check.orderTotalIs('11.88');
PosLoyalty.exec.finalizeOrder('Cash', '20');
ProductScreen.exec.addOrderline('Magnetic Board', '6');
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
PosLoyalty.check.hasRewardLine('Free Product - Whiteboard Pen', '-3.20', '1.00');
PosLoyalty.check.isRewardButtonHighlighted(true);
ProductScreen.do.clickOrderline('Magnetic Board', '6.00');
ProductScreen.do.pressNumpad('Backspace');
// At this point, the reward should have been removed.
PosLoyalty.check.isRewardButtonHighlighted(false);
ProductScreen.check.selectedOrderlineHas('Magnetic Board', '0.00');
ProductScreen.do.clickDisplayedProduct('Magnetic Board');
ProductScreen.check.selectedOrderlineHas('Magnetic Board', '1.00');
ProductScreen.do.clickDisplayedProduct('Magnetic Board');
ProductScreen.check.selectedOrderlineHas('Magnetic Board', '2.00');
ProductScreen.do.clickDisplayedProduct('Magnetic Board');
ProductScreen.check.selectedOrderlineHas('Magnetic Board', '3.00');
PosLoyalty.check.hasRewardLine('Free Product - Whiteboard Pen', '-3.20', '1.00');
PosLoyalty.check.isRewardButtonHighlighted(false);
PosLoyalty.check.orderTotalIs('5.94');
PosLoyalty.exec.finalizeOrder('Cash', '10');
// Promotion: 2 items of shelves, get desk_pad/monitor_stand free
// This is the 5th order.
ProductScreen.exec.addOrderline('Wall Shelf Unit', '1');
PosLoyalty.check.isRewardButtonHighlighted(false);
ProductScreen.exec.addOrderline('Small Shelf', '1');
PosLoyalty.check.isRewardButtonHighlighted(true);
// Click reward product. Should be automatically added as reward.
ProductScreen.do.clickDisplayedProduct('Desk Pad');
PosLoyalty.check.isRewardButtonHighlighted(false);
PosLoyalty.check.hasRewardLine('Free Product', '-1.98', '1.00');
// Remove the reward line. The next steps will check if cashier
// can select from the different reward products.
ProductScreen.do.pressNumpad('Backspace');
ProductScreen.do.pressNumpad('Backspace');
PosLoyalty.check.isRewardButtonHighlighted(true);
PosLoyalty.do.clickRewardButton();
SelectionPopup.check.hasSelectionItem('Monitor Stand');
SelectionPopup.check.hasSelectionItem('Desk Pad');
SelectionPopup.do.clickItem('Desk Pad');
PosLoyalty.check.isRewardButtonHighlighted(false);
PosLoyalty.check.hasRewardLine('Free Product', '-1.98', '1.00');
ProductScreen.do.pressNumpad('Backspace');
ProductScreen.do.pressNumpad('Backspace');
PosLoyalty.check.isRewardButtonHighlighted(true);
PosLoyalty.do.claimReward('Monitor Stand');
PosLoyalty.check.isRewardButtonHighlighted(false);
ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.00', '3.19');
PosLoyalty.check.hasRewardLine('Free Product', '-3.19', '1.00');
PosLoyalty.check.orderTotalIs('4.81');
PosLoyalty.exec.finalizeOrder('Cash', '10');
Tour.register('PosLoyaltyFreeProductTour', { test: true, url: '/pos/web' }, getSteps());
@@ -59,7 +59,7 @@ odoo.define('pos_loyalty.tour.PosCouponTourMethods', function (require) {
},
];
}
claimSingleReward() {
clickRewardButton() {
return [
{
content: 'open reward dialog',
@@ -79,11 +79,14 @@ odoo.define('pos_loyalty.tour.PosCouponTourMethods', function (require) {
}
];
}
unselectPartner() {
return [{ trigger: '.unselect-tag' }];
}
}
class Check {
hasRewardLine(rewardName, amount) {
return [
hasRewardLine(rewardName, amount, qty) {
const steps = [
{
content: 'check if reward line is there',
trigger: `.orderline.program-reward span.product-name:contains("${rewardName}")`,
@@ -95,6 +98,14 @@ odoo.define('pos_loyalty.tour.PosCouponTourMethods', function (require) {
run: function () {},
},
];
if (qty) {
steps.push({
content: 'check if the reward qty is correct',
trigger: `.order .orderline.program-reward .product-name:contains("${rewardName}") ~ .info-list em:contains("${qty}")`,
run: function () {},
});
}
return steps;
}
orderTotalIs(total_str) {
return [
@@ -114,6 +125,24 @@ odoo.define('pos_loyalty.tour.PosCouponTourMethods', function (require) {
}
]
}
isRewardButtonHighlighted(isHighlighted) {
return [
{
trigger: isHighlighted
? '.control-button.highlight:contains("Reward")'
: '.control-button:contains("Reward"):not(:has(.highlight))',
run: function () {}, // it's a check
},
];
}
customerIs(name) {
return [
{
trigger: `.actionpad button.set-partner:contains("${name}")`,
run: function () {},
}
]
}
}
class Execute {
+141
View File
@@ -209,3 +209,144 @@ class TestUi(TestPointOfSaleHttpCommon):
"PosLoyaltyValidity2",
login="accountman",
)
def test_loyalty_free_product_rewards(self):
free_product = self.env['loyalty.program'].create({
'name': 'Buy 2 Take 1 desk_organizer',
'program_type': 'promotion',
'trigger': 'auto',
'applies_on': 'current',
'rule_ids': [(0, 0, {
'product_ids': self.desk_organizer,
'reward_point_mode': 'unit',
'minimum_qty': 0,
})],
'reward_ids': [(0, 0, {
'reward_type': 'product',
'reward_product_id': self.desk_organizer.id,
'reward_product_qty': 1,
'required_points': 2,
})],
})
free_other_product = self.env['loyalty.program'].create({
'name': 'Buy 3 magnetic_board, Take 1 whiteboard_pen',
'program_type': 'promotion',
'trigger': 'auto',
'applies_on': 'current',
'rule_ids': [(0, 0, {
'product_ids': self.magnetic_board,
'reward_point_mode': 'unit',
'minimum_qty': 0,
})],
'reward_ids': [(0, 0, {
'reward_type': 'product',
'reward_product_id': self.whiteboard_pen.id,
'reward_product_qty': 1,
'required_points': 3,
})],
})
free_multi_product = self.env['loyalty.program'].create({
'name': '2 items of shelves, get desk_pad/monitor_stand free',
'program_type': 'promotion',
'trigger': 'auto',
'applies_on': 'current',
'rule_ids': [(0, 0, {
'product_ids': (self.wall_shelf | self.small_shelf).ids,
'reward_point_mode': 'unit',
'minimum_qty': 0,
})],
'reward_ids': [(0, 0, {
'reward_type': 'product',
'reward_product_tag_id': self.env['product.tag'].create({
'name': 'reward_product_tag',
'product_product_ids': (self.desk_pad | self.monitor_stand).ids,
}).id,
'reward_product_qty': 1,
'required_points': 2,
})],
})
self.main_pos_config.write({
'promo_program_ids': [Command.set([free_product.id, free_other_product.id, free_multi_product.id])]
})
self.start_tour(
"/pos/web?config_id=%d" % self.main_pos_config.id,
"PosLoyaltyFreeProductTour",
login="accountman",
)
# Keep the tour to generate 4 orders for the free_product and free_other_product programs.
# 2 of them don't use a program.
# 1 uses free_product.
# 1 uses free_other_product.
# This is to take into account the fact that during tours, we can't test the "non-occurence" of something.
# It would be nice to have a check like: Validate that a reward is "not" there.
self.assertEqual(free_product.pos_order_count, 1)
self.assertEqual(free_other_product.pos_order_count, 2)
# There is the 5th order that tests multi_product reward.
# It attempted to add one reward product, removed it, then add the second.
# The second reward was synced with the order.
self.assertEqual(free_multi_product.pos_order_count, 1)
def test_loyalty_free_product_loyalty_program(self):
# In this program, each whiteboard pen gives 1 point.
# 4 points can be used to get a free whiteboard pen.
loyalty_program = self.env['loyalty.program'].create({
'name': 'Buy 4 whiteboard_pen, Take 1 whiteboard_pen',
'program_type': 'loyalty',
'trigger': 'auto',
'applies_on': 'both',
'rule_ids': [(0, 0, {
'product_ids': self.whiteboard_pen.ids,
'reward_point_mode': 'unit',
'minimum_qty': 1,
})],
'reward_ids': [(0, 0, {
'reward_type': 'product',
'reward_product_id': self.whiteboard_pen.id,
'reward_product_qty': 1,
'required_points': 4,
})],
})
partner_aaa = self.env['res.partner'].create({'name': 'Test Partner AAA'})
partner_bbb = self.env['res.partner'].create({'name': 'Test Partner BBB'})
partner_ccc = self.env['res.partner'].create({'name': 'Test Partner CCC'})
self.main_pos_config.write({
'promo_program_ids': [Command.clear()],
'module_pos_loyalty': True,
'loyalty_program_id': loyalty_program.id,
})
# Part 1
self.start_tour(
"/pos/web?config_id=%d" % self.main_pos_config.id,
"PosLoyaltyLoyaltyProgram1",
login="accountman",
)
aaa_loyalty_card = loyalty_program.coupon_ids.filtered(lambda coupon: coupon.partner_id.id == partner_aaa.id)
self.assertEqual(loyalty_program.pos_order_count, 1)
self.assertAlmostEqual(aaa_loyalty_card.points, 4)
# Part 2
self.start_tour(
"/pos/web?config_id=%d" % self.main_pos_config.id,
"PosLoyaltyLoyaltyProgram2",
login="accountman",
)
self.assertEqual(loyalty_program.pos_order_count, 2, msg='Only 2 orders should have reward lines.')
self.assertAlmostEqual(aaa_loyalty_card.points, 1)
bbb_loyalty_card = loyalty_program.coupon_ids.filtered(lambda coupon: coupon.partner_id.id == partner_bbb.id)
ccc_loyalty_card = loyalty_program.coupon_ids.filtered(lambda coupon: coupon.partner_id.id == partner_ccc.id)
self.assertAlmostEqual(bbb_loyalty_card.points, 3, msg='Reference: Order3_BBB')
self.assertAlmostEqual(ccc_loyalty_card.points, 4, msg='Reference: Order2_CCC')
reward_orderline = self.main_pos_config.current_session_id.order_ids[-1].lines.filtered(lambda line: line.is_reward_line)
self.assertEqual(len(reward_orderline.ids), 0, msg='Reference: Order4_no_reward. Last order should have no reward line.')