[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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.')
|
||||
|
||||
Reference in New Issue
Block a user