From 75d14c22824ddaa6c496b7e41eb185f2c4d70e1d Mon Sep 17 00:00:00 2001 From: Joseph Caburnay Date: Wed, 25 May 2022 09:47:29 +0000 Subject: [PATCH] [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 --- .../js/Screens/ProductScreen/ProductScreen.js | 5 +- .../src/js/ControlButtons/RewardButton.js | 61 ++- addons/pos_loyalty/static/src/js/Loyalty.js | 371 +++++++++++------- .../static/src/js/PaymentScreen.js | 4 +- .../static/src/js/ProductScreen.js | 25 +- .../js/tours/PosLoyaltyLoyaltyProgramTour.js | 142 +++++++ .../js/tours/PosLoyaltyRewardButtonTour.js | 130 ++++++ .../src/js/tours/PosLoyaltyTourMethods.js | 35 +- addons/pos_loyalty/tests/test_frontend.py | 141 +++++++ 9 files changed, 760 insertions(+), 154 deletions(-) create mode 100644 addons/pos_loyalty/static/src/js/tours/PosLoyaltyLoyaltyProgramTour.js create mode 100644 addons/pos_loyalty/static/src/js/tours/PosLoyaltyRewardButtonTour.js diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js index dea4eba6c06..158cfc4daf7 100644 --- a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js @@ -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) { diff --git a/addons/pos_loyalty/static/src/js/ControlButtons/RewardButton.js b/addons/pos_loyalty/static/src/js/ControlButtons/RewardButton.js index a4c2e685704..0aefd5cde2b 100644 --- a/addons/pos_loyalty/static/src/js/ControlButtons/RewardButton.js +++ b/addons/pos_loyalty/static/src/js/ControlButtons/RewardButton.js @@ -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; diff --git a/addons/pos_loyalty/static/src/js/Loyalty.js b/addons/pos_loyalty/static/src/js/Loyalty.js index b74f3286a09..fb27357377c 100644 --- a/addons/pos_loyalty/static/src/js/Loyalty.js +++ b/addons/pos_loyalty/static/src/js/Loyalty.js @@ -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, diff --git a/addons/pos_loyalty/static/src/js/PaymentScreen.js b/addons/pos_loyalty/static/src/js/PaymentScreen.js index 87b9052ebf9..c73301ff068 100644 --- a/addons/pos_loyalty/static/src/js/PaymentScreen.js +++ b/addons/pos_loyalty/static/src/js/PaymentScreen.js @@ -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; diff --git a/addons/pos_loyalty/static/src/js/ProductScreen.js b/addons/pos_loyalty/static/src/js/ProductScreen.js index c2e6b1f3bc5..77125aa0145 100644 --- a/addons/pos_loyalty/static/src/js/ProductScreen.js +++ b/addons/pos_loyalty/static/src/js/ProductScreen.js @@ -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]; diff --git a/addons/pos_loyalty/static/src/js/tours/PosLoyaltyLoyaltyProgramTour.js b/addons/pos_loyalty/static/src/js/tours/PosLoyaltyLoyaltyProgramTour.js new file mode 100644 index 00000000000..572f4501d58 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/tours/PosLoyaltyLoyaltyProgramTour.js @@ -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()); diff --git a/addons/pos_loyalty/static/src/js/tours/PosLoyaltyRewardButtonTour.js b/addons/pos_loyalty/static/src/js/tours/PosLoyaltyRewardButtonTour.js new file mode 100644 index 00000000000..798ba7e5860 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/tours/PosLoyaltyRewardButtonTour.js @@ -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()); diff --git a/addons/pos_loyalty/static/src/js/tours/PosLoyaltyTourMethods.js b/addons/pos_loyalty/static/src/js/tours/PosLoyaltyTourMethods.js index eb0096d5576..73feb7e0087 100644 --- a/addons/pos_loyalty/static/src/js/tours/PosLoyaltyTourMethods.js +++ b/addons/pos_loyalty/static/src/js/tours/PosLoyaltyTourMethods.js @@ -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 { diff --git a/addons/pos_loyalty/tests/test_frontend.py b/addons/pos_loyalty/tests/test_frontend.py index e13b38cbde4..378aa6f0a76 100644 --- a/addons/pos_loyalty/tests/test_frontend.py +++ b/addons/pos_loyalty/tests/test_frontend.py @@ -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.')