From 203d3035d135a2c1d87de7fa39d5d980a3cdb7d8 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Thu, 28 May 2026 14:50:21 +0700 Subject: [PATCH] first commit --- .gitignore | 68 +++ README.md | 132 ++++++ __init__.py | 2 + __manifest__.py | 22 + models/__init__.py | 3 + models/loyalty_reward.py | 63 +++ models/sale_order.py | 65 +++ static/src/app/models/pos_order.js | 429 ++++++++++++++++++ .../control_buttons/control_buttons.js | 118 +++++ static/src/app/services/pos_store.js | 76 ++++ views/loyalty_reward_views.xml | 22 + 11 files changed, 1000 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 models/__init__.py create mode 100644 models/loyalty_reward.py create mode 100644 models/sale_order.py create mode 100644 static/src/app/models/pos_order.js create mode 100644 static/src/app/screens/product_screen/control_buttons/control_buttons.js create mode 100644 static/src/app/services/pos_store.js create mode 100644 views/loyalty_reward_views.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c06c4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# === Python cache === +__pycache__/ +*.py[cod] +*$py.class +*.pyc +*.pyo + +# === Odoo / Python runtime === +*.egg-info/ +*.egg +.eggs/ +dist/ +build/ +.installed.cfg +*.cfg.bak + +# === Virtual environments === +.venv/ +venv/ +env/ +ENV/ + +# === IDE & Editor files === +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# === Odoo specific === +# Compiled JS/CSS assets (generated by Odoo's asset bundler) +/static/description/icon.png.bak + +# Odoo log files +*.log + +# Odoo filestore (attachments, session data) +filestore/ +sessions/ + +# Local Odoo config overrides (keep odoo.conf out of version control) +odoo.conf +*.conf.local + +# === Test artifacts === +.coverage +htmlcov/ +.pytest_cache/ +*.test.js.snap + +# === Node / Frontend tooling (if used) === +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm/ + +# === OS files === +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini diff --git a/README.md b/README.md new file mode 100644 index 0000000..fdfa564 --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# POS Loyalty Extend + +**Version:** 1.0 +**Author:** Suherdy Yacob +**License:** LGPL-3 +**Category:** Sales / Point of Sale + +## Overview + +`pos_loyalty_extend` is a custom Odoo 19 module that extends the built-in `pos_loyalty` module with additional reward applicability options in the Point of Sale interface. + +The primary feature added is **"Cheapest Product on Order"** applicability for **Buy X Get Y** loyalty rewards — allowing merchants to automatically discount or give away the cheapest item(s) in a customer's cart based on the number of products purchased. + +--- + +## Features + +### 🏷️ Cheapest Product Applicability +- Adds a new `reward_product_applicability` field (`cheapest`) to `loyalty.reward`. +- When set, the reward dynamically targets the cheapest product(s) in the current POS order instead of a pre-configured fixed product. + +### 📊 Proportional Free Item Calculation +The number of free items scales automatically based on the buy ratio: + +| Products Bought | Free Items | +|---|---| +| 2 | 1 | +| 3 | 1 | +| 4 | 2 | +| 5 | 2 | +| 6 | 3 | + +Formula: `floor(points_earned / required_points_per_reward)` + +### 🛒 Multi-Product Free Lines +When multiple free items are awarded, each one targets a **different cheapest product** in the order (sorted by unit price ascending), rather than duplicating the single cheapest item: + +- Buy 4 items → 2 free: **Terong Penyet (Rp 15,000)** + **Telor Penyet (Rp 19,000)** ✅ +- ~~Buy 4 items → 2 free: 2× Terong Penyet (Rp 15,000)~~ ❌ + +### ⚡ Auto-Claim in POS +Cheapest product rewards are automatically discovered and claimed in the POS UI without requiring manual product configuration on the reward. + +--- + +## Dependencies + +| Module | Purpose | +|---|---| +| `point_of_sale` | Core POS framework | +| `pos_loyalty` | Loyalty program UI and logic in POS | +| `sale_loyalty` | Backend loyalty program models | + +--- + +## Configuration + +### Setting Up a "Buy 2 Get 1 Free (Cheapest)" Program + +1. Go to **Point of Sale → Products → Loyalty Programs** +2. Create a new program with type **Loyalty Card** or **Promotion** +3. Under **Conditional Rules**: + - **Minimum Quantity:** `2` + - **Grant:** `1 Credit per Unit Paid` +4. Under **Rewards** → Add a reward: + - **Reward Type:** `Free Product` + - **Reward Product Applicability:** `Cheapest Product on Order` *(new field)* + - **In Exchange of:** `2 Credits` *(1 credit per product, 2 needed → 1 free)* + - **Quantity Rewarded:** `1` + +> **Important:** Set "In Exchange of" to `2` (not `1`) to get the correct `buy 2 → 1 free` ratio. +> The formula is `floor(total_credits / credits_required) = free_items`. + +--- + +## Technical Details + +### Modified Files + +#### Python (Backend) +| File | Description | +|---|---| +| `models/loyalty_reward.py` | Adds `reward_product_applicability` selection field; overrides `_compute_multi_product` to handle cheapest type | +| `models/sale_order.py` | Overrides reward value computation for server-side cheapest product identification | + +#### JavaScript (Frontend) +| File | Description | +|---|---| +| `static/src/app/models/pos_order.js` | Core reward logic patches — cheapest product detection, unclaimed qty computation, multi-product reward line generation, `getClaimableRewards` override | +| `static/src/app/services/pos_store.js` | Patches `getPotentialFreeProductRewards` to surface cheapest rewards with no fixed `reward_product_ids` | +| `static/src/app/screens/product_screen/control_buttons/control_buttons.js` | Patches `_applyReward` to route cheapest rewards through dynamic product resolution | + +#### Views +| File | Description | +|---|---| +| `views/loyalty_reward_views.xml` | Adds the `reward_product_applicability` field to the loyalty reward form | + +### Key Methods + +- **`_getCheapestProductInOrder(reward)`** — Returns the single cheapest non-reward product in the current order +- **`_getCheapestProductsInOrder(reward, n)`** — Returns the N cheapest individual items (sorted by unit price), expanding multi-qty lines +- **`_computeUnclaimedFreeProductQtyForCheapest(...)`** — Calculates how many free items are still unclaimed based on remaining loyalty points +- **`getClaimableRewards(...)`** — Extended to include cheapest-type rewards that the core skips (due to null `reward_product_id`) + +--- + +## Installation + +```bash +# Copy module to your custom addons path +cp -r pos_loyalty_extend /path/to/odoo/customaddons/ + +# Update addons list and install +./odoo-bin -c odoo.conf -u pos_loyalty_extend +``` + +Or via Odoo UI: +1. Enable **Developer Mode** +2. Go to **Apps → Update Apps List** +3. Search for `POS Loyalty Extend` and install + +--- + +## Changelog + +### v1.0 +- Initial release +- Added `cheapest` reward product applicability +- Auto-claim cheapest product rewards in POS +- Proportional multi-product free item generation (N cheapest products, not N× same product) +- Fixed `getClaimableRewards` and `getPotentialFreeProductRewards` to surface cheapest rewards +- Fixed `_updateRewardLines` deduplication for multiple reward lines per reward diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a0fdc10 --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..70b44f5 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'POS Loyalty Extend', + 'version': '1.0', + 'category': 'Sales/Point of Sale', + 'summary': 'Extends loyalty rewards to support cheapest product applicability and cleaner variant selection in POS.', + 'author': 'Suherdy Yacob', + 'depends': ['point_of_sale', 'pos_loyalty', 'sale_loyalty'], + 'data': [ + 'views/loyalty_reward_views.xml', + ], + 'assets': { + 'point_of_sale._assets_pos': [ + 'pos_loyalty_extend/static/src/app/models/pos_order.js', + 'pos_loyalty_extend/static/src/app/services/pos_store.js', + 'pos_loyalty_extend/static/src/app/screens/product_screen/control_buttons/control_buttons.js', + ], + }, + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..0572a9d --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import loyalty_reward +from . import sale_order diff --git a/models/loyalty_reward.py b/models/loyalty_reward.py new file mode 100644 index 0000000..cdd27b4 --- /dev/null +++ b/models/loyalty_reward.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api +from odoo.fields import Domain + +class LoyaltyReward(models.Model): + _inherit = 'loyalty.reward' + + reward_product_applicability = fields.Selection([ + ('specific', 'Specific Product(s)'), + ('cheapest', 'Cheapest Product on Order') + ], string='Reward Product Applicability', default='specific', required=True) + + reward_product_category_id = fields.Many2one( + 'product.category', string='Reward Product Category', + help='If specified, only products under this category (including subcategories) will be considered for the cheapest product reward.' + ) + + @api.depends('reward_product_id', 'reward_product_tag_id', 'reward_type', 'reward_product_applicability', 'reward_product_category_id') + def _compute_multi_product(self): + super()._compute_multi_product() + for reward in self: + if reward.reward_type == 'product' and reward.reward_product_applicability == 'cheapest': + products = reward.reward_product_id + reward.reward_product_tag_id.product_ids.filtered( + lambda product: product.type != 'combo' + ) + if reward.reward_product_category_id: + category_ids = self.env['product.category'].search([ + ('id', 'child_of', reward.reward_product_category_id.ids) + ]).ids + cat_products = self.env['product.product'].search([ + ('categ_id', 'in', category_ids) + ]) + products |= cat_products.filtered(lambda product: product.type != 'combo') + reward.multi_product = False + reward.reward_product_ids = products + + @api.model + def _load_pos_data_fields(self, config): + fields = super()._load_pos_data_fields(config) + if 'reward_product_applicability' not in fields: + fields.append('reward_product_applicability') + if 'reward_product_category_id' not in fields: + fields.append('reward_product_category_id') + return fields + + @api.model + def _load_pos_data_domain(self, data, config): + reward_product_tag_domain = [ + ('reward_product_tag_id', '!=', False), + '|', + ('reward_product_tag_id.product_template_ids.active', '=', True), + ('reward_product_tag_id.product_product_ids.active', '=', True), + ] + return Domain.AND([ + [('program_id', 'in', config._get_program_ids().ids)], + Domain.OR([ + [('reward_type', '!=', 'product')], + [('reward_product_id.active', '=', True)], + [('reward_product_applicability', '=', 'cheapest')], + reward_product_tag_domain, + ]), + ]) diff --git a/models/sale_order.py b/models/sale_order.py new file mode 100644 index 0000000..12024be --- /dev/null +++ b/models/sale_order.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- + +from odoo import models, api, _ +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_round +from odoo.fields import Command +import random + +def _generate_random_reward_code(): + return f"REWARD-{random.randint(100000, 999999)}" + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def _get_cheapest_matching_product(self, reward): + self.ensure_one() + candidate_lines = self.order_line.filtered( + lambda l: not l.is_reward_line and l.product_uom_qty > 0 and l.price_unit > 0 + ) + if reward.reward_product_ids: + candidate_lines = candidate_lines.filtered( + lambda l: l.product_id in reward.reward_product_ids + ) + if not candidate_lines: + return None + cheapest_line = min(candidate_lines, key=lambda l: l.price_unit) + return cheapest_line.product_id + + def _get_reward_values_product(self, reward, coupon, product=None, **kwargs): + self.ensure_one() + if reward.reward_product_applicability != 'cheapest': + return super()._get_reward_values_product(reward, coupon, product=product, **kwargs) + + # Cheapest product applicability logic + if not product: + product = self._get_cheapest_matching_product(reward) + if not product: + # Fallback if no eligible product in cart + if reward.reward_product_ids: + product = reward.reward_product_ids[:1] + else: + first_line = self.order_line.filtered(lambda l: not l.is_reward_line and l.product_uom_qty > 0)[:1] + product = first_line.product_id + + if not product: + raise UserError(_("No eligible product in the cart to apply the cheapest product reward.")) + + # Compute reward values directly to support global cheapest reward + taxes = self.fiscal_position_id.map_tax(product.taxes_id._filter_taxes_by_company(self.company_id)) + points = self._get_real_points_for_coupon(coupon) + claimable_count = float_round(points / reward.required_points, precision_rounding=1, rounding_method='DOWN') if not reward.clear_wallet else 1 + cost = points if reward.clear_wallet else claimable_count * reward.required_points + + return [{ + 'name': reward.description or _("Free Product (Cheapest)"), + 'product_id': product.id, + 'discount': 100, + 'product_uom_qty': reward.reward_product_qty * claimable_count, + 'reward_id': reward.id, + 'coupon_id': coupon.id, + 'points_cost': cost, + 'reward_identifier_code': _generate_random_reward_code(), + 'sequence': max(self.order_line.filtered(lambda x: not x.is_reward_line).mapped('sequence'), default=10) + 1, + 'tax_ids': [Command.clear()] + [Command.link(tax.id) for tax in taxes], + }] diff --git a/static/src/app/models/pos_order.js b/static/src/app/models/pos_order.js new file mode 100644 index 0000000..28041c7 --- /dev/null +++ b/static/src/app/models/pos_order.js @@ -0,0 +1,429 @@ +/** @odoo-module **/ + +import { PosOrder } from "@point_of_sale/app/models/pos_order"; +import { patch } from "@web/core/utils/patch"; +import { _t } from "@web/core/l10n/translation"; + +patch(PosOrder.prototype, { + /** + * Returns the single cheapest product in the order (used for qty/availability checks). + */ + _getCheapestProductInOrder(reward) { + const orderLines = this.getOrderlines(); + let cheapestProduct = null; + let lowestPrice = Infinity; + + const rewardProductIds = new Set((reward?.reward_product_ids || []).map(p => p.id)); + + for (const line of orderLines || []) { + if (line.is_reward_line || line.qty <= 0 || line.price_unit <= 0) { + continue; + } + const product = line.product_id; + if (!product) { + continue; + } + if (rewardProductIds.size > 0 && !rewardProductIds.has(product.id)) { + continue; + } + const price = line.price_unit; + if (price < lowestPrice) { + lowestPrice = price; + cheapestProduct = product; + } + } + return cheapestProduct; + }, + + /** + * Returns an array of the N cheapest individual cart items, sorted by unit price ascending. + * Each entry: { product, price }. Items with qty > 1 are expanded individually. + * Used to build multiple reward lines (1st cheapest free, 2nd cheapest free, etc.). + */ + _getCheapestProductsInOrder(reward, n) { + const rewardProductIds = new Set((reward?.reward_product_ids || []).map(p => p.id)); + + const candidateLines = this.getOrderlines().filter(line => { + if (line.is_reward_line || line.qty <= 0 || line.price_unit <= 0) return false; + if (rewardProductIds.size > 0 && !rewardProductIds.has(line.product_id?.id)) return false; + return true; + }); + + // Sort by unit price ascending + const sorted = [...candidateLines].sort((a, b) => a.price_unit - b.price_unit); + + // Expand by qty so we correctly handle multi-qty lines + const items = []; + for (const line of sorted) { + const qty = Math.floor(line.getQuantity()); + for (let i = 0; i < qty && items.length < n; i++) { + items.push({ product: line.product_id, price: line.price_unit }); + } + if (items.length >= n) break; + } + return items; + }, + + _getRewardLineValuesProduct(args) { + const reward = args["reward"]; + if (reward && reward.reward_product_applicability === 'cheapest') { + // Need at least one item to determine the cheapest + const cheapest = this._getCheapestProductInOrder(reward); + if (!cheapest) { + return _t("There are not enough products in the basket to claim this reward."); + } + const points = this._getRealCouponPoints(args["coupon_id"]); + const unclaimedQty = this._computeUnclaimedFreeProductQtyForCheapest( + reward, args["coupon_id"], cheapest, points + ); + if (unclaimedQty <= 0) { + return _t("There are not enough products in the basket to claim this reward."); + } + const claimable_count = reward.clear_wallet + ? 1 + : Math.min( + Math.ceil(unclaimedQty / reward.reward_product_qty), + Math.floor(points / reward.required_points) + ); + const totalCost = reward.clear_wallet + ? points + : Math.min(claimable_count * reward.required_points, args["cost"] || Infinity); + const freeQuantity = Math.min( + unclaimedQty, + reward.reward_product_qty * claimable_count, + args["quantity"] || Infinity + ); + + // Get the N cheapest individual items — each gets its own free line. + // e.g. buy 4 → 2 free: Terong (15k) + Telor (19k), not 2× Terong (15k) + const cheapestItems = this._getCheapestProductsInOrder(reward, freeQuantity); + + if (!cheapestItems.length) { + return _t("There are not enough products in the basket to claim this reward."); + } + + // Group by product to merge identical items into one line each + const productGroups = new Map(); + for (const { product, price } of cheapestItems) { + const key = `${product.id}:${price}`; + if (!productGroups.has(key)) { + productGroups.set(key, { product, qty: 0, price }); + } + productGroups.get(key).qty++; + } + + const actualFreeQty = cheapestItems.length; + const costPerItem = totalCost / actualFreeQty; + const rewardCode = (Math.random() + 1).toString(36).substring(3); + const rewardLines = []; + + for (const { product, qty, price } of productGroups.values()) { + rewardLines.push({ + product_id: reward.discount_line_product_id, + price_unit: -this.currency.round(price), + tax_ids: product.taxes_id, + qty: qty, + reward_id: reward, + is_reward_line: true, + _reward_product_id: product, + coupon_id: args["coupon_id"], + points_cost: this.currency.round(costPerItem * qty), + reward_identifier_code: rewardCode, + }); + } + + return rewardLines; + } + return super._getRewardLineValuesProduct(...arguments); + }, + + /** + * Standalone unclaimed qty computation for 'cheapest' rewards. + * For "Buy X Get Y (cheapest)" the free items are additional discount lines, + * NOT limited to how many of the cheapest product are already in the cart. + * The free qty is determined purely by: floor(points / required_points) * reward_product_qty. + */ + _computeUnclaimedFreeProductQtyForCheapest(reward, coupon_id, product, remainingPoints) { + let claimed = 0; + for (const line of this.getOrderlines()) { + if (!line.is_reward_line) continue; + if (line.reward_id && line.reward_id.id === reward.id) { + // Restore points spent on existing reward lines so we don't double-count + remainingPoints += line.points_cost; + claimed += Math.abs(line.getQuantity()); + } + } + // Compute freeQty based solely on remaining points — no cart-availability cap. + // Example: required_points=2, earn 1/unit → buy 5 → 5 pts → floor(5/2)=2 free. + const freeQty = Math.floor( + (remainingPoints / reward.required_points) * reward.reward_product_qty + ); + return freeQty - claimed; + }, + + _computeUnclaimedFreeProductQty(reward, coupon_id, product, remainingPoints) { + if (reward && reward.reward_product_applicability === 'cheapest') { + const cheapest = this._getCheapestProductInOrder(reward); + if (cheapest) { + product = cheapest; + } + return this._computeUnclaimedFreeProductQtyForCheapest(reward, coupon_id, product, remainingPoints); + } + return super._computeUnclaimedFreeProductQty(reward, coupon_id, product, remainingPoints); + }, + + /** + * Override getClaimableRewards to handle 'cheapest' applicability rewards. + * The core method skips rewards where reward_product_id is null/false + * (which is the case for cheapest rewards that have no specific product), + * so we intercept and compute the unclaimed qty dynamically. + */ + getClaimableRewards(coupon_id = false, program_id = false, auto = false) { + const baseResult = super.getClaimableRewards(coupon_id, program_id, auto); + + // Find cheapest-applicability rewards that may have been skipped by the core + const couponPointChanges = this.uiState.couponPointChanges; + const excludedCouponIds = Object.keys(couponPointChanges) + .filter((id) => couponPointChanges[id].manual && couponPointChanges[id].existing_code) + .map((id) => couponPointChanges[id].coupon_id); + + const allCouponPrograms = Object.values(this.uiState.couponPointChanges) + .filter((pe) => !excludedCouponIds.includes(pe.coupon_id)) + .map((pe) => ({ + program_id: pe.program_id, + coupon_id: pe.coupon_id, + })) + .concat( + this._code_activated_coupon_ids.map((coupon) => ({ + program_id: coupon.program_id.id, + coupon_id: coupon.id, + })) + ); + + // Track which rewards are already in baseResult + const baseResultRewardIds = new Set(baseResult.map((r) => r.reward.id)); + + for (const couponProgram of allCouponPrograms) { + const program = this.models['loyalty.program'].get(couponProgram.program_id); + if (!program) continue; + + if ( + (coupon_id && couponProgram.coupon_id !== coupon_id) || + (program_id && couponProgram.program_id !== program_id) + ) { + continue; + } + + const points = this._getRealCouponPoints(couponProgram.coupon_id); + + for (const reward of program.reward_ids) { + // Only handle cheapest-applicability product rewards not already in result + if ( + reward.reward_type !== 'product' || + reward.reward_product_applicability !== 'cheapest' || + baseResultRewardIds.has(reward.id) + ) { + continue; + } + if (points < reward.required_points) { + continue; + } + if (auto && this.uiState.disabledRewards.has(reward.id)) { + continue; + } + + // Compute unclaimed qty using cheapest product in the order + const cheapest = this._getCheapestProductInOrder(reward); + if (!cheapest) { + continue; + } + const unclaimedQty = this._computeUnclaimedFreeProductQtyForCheapest( + reward, + couponProgram.coupon_id, + cheapest, + points + ); + if (!unclaimedQty || unclaimedQty <= 0) { + continue; + } + + baseResult.push({ + coupon_id: couponProgram.coupon_id, + reward: reward, + potentialQty: unclaimedQty, + }); + baseResultRewardIds.add(reward.id); + } + } + + return baseResult; + }, + + _computePotentialFreeProductQty(reward, product, remainingPoints) { + if (reward && reward.reward_product_applicability === 'cheapest') { + const cheapest = this._getCheapestProductInOrder(reward); + if (cheapest) { + product = cheapest; + } + // For potential qty, just check if there are enough points + return Math.floor( + (remainingPoints / reward.required_points) * reward.reward_product_qty + ); + } + return super._computePotentialFreeProductQty(reward, product, remainingPoints); + }, + + /** + * Override _updateRewardLines to prevent infinite loop caused by + * cheapest-product rewards changing price when the order changes. + * We exclude cheapest-reward lines from the "amount changed" signal. + */ + _updateRewardLines() { + if (!this.lines.length) { + return; + } + const rewardLines = this._get_reward_lines(); + if (!rewardLines.length) { + return; + } + + // Separate cheapest product reward lines from others + const cheapestRewardLines = rewardLines.filter( + line => line.reward_id && line.reward_id.reward_product_applicability === 'cheapest' + ); + const otherRewardLines = rewardLines.filter( + line => !(line.reward_id && line.reward_id.reward_product_applicability === 'cheapest') + ); + + // If there are cheapest reward lines, handle them separately. + // Group by (reward.id + coupon_id) so we delete ALL existing lines for a reward + // first, then re-apply ONCE — preventing duplicate lines from calling _applyReward + // once per old line (which was the previous bug). + if (cheapestRewardLines.length > 0) { + const cheapestRewardsByKey = new Map(); + for (const line of cheapestRewardLines) { + const key = `${line.reward_id.id}:${line.coupon_id?.id}`; + if (!cheapestRewardsByKey.has(key)) { + cheapestRewardsByKey.set(key, { + reward: line.reward_id, + coupon_id: line.coupon_id?.id, + lines: [], + }); + } + cheapestRewardsByKey.get(key).lines.push(line); + } + + for (const { reward, coupon_id, lines } of cheapestRewardsByKey.values()) { + // Validate coupon is still active + if ( + !this._code_activated_coupon_ids.find(c => c.id === coupon_id) && + !this.uiState.couponPointChanges[coupon_id] + ) { + for (const line of lines) line.delete(); + continue; + } + // Delete ALL existing lines for this (reward, coupon) pair first + for (const line of lines) line.delete(); + // Re-apply once — generates correct N lines for N cheapest products + this._applyReward(reward, coupon_id, {}); + } + + // If only cheapest lines existed — return false (no loop-triggering change) + if (otherRewardLines.length === 0) { + return false; + } + } + + // For the remaining (non-cheapest) reward lines, use the standard logic inline. + // (We don't call super() because cheapest lines were already deleted & recreated above, + // and super() re-reads all reward lines which would double-process them.) + const productRewards = []; + const otherRewards = []; + const paymentRewards = []; + for (const line of otherRewardLines) { + const claimedReward = { + reward: line.reward_id, + coupon_id: line.coupon_id?.id, + args: { + product: line._reward_product_id, + price: line.price_unit, + quantity: line.qty, + cost: line.points_cost, + }, + reward_identifier_code: line.reward_identifier_code, + }; + 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") { + productRewards.push(claimedReward); + } else if ( + !otherRewards.some( + (reward) => + reward.reward_identifier_code === claimedReward.reward_identifier_code + ) + ) { + otherRewards.push(claimedReward); + } + line.delete(); + } + const allRewards = productRewards.concat(otherRewards).concat(paymentRewards); + const allRewardsMerged = []; + allRewards.forEach((reward) => { + if (reward.reward.reward_type == "discount") { + allRewardsMerged.push(reward); + } else { + const reward_index = allRewardsMerged.findIndex( + (item) => + item.reward.id === reward.reward.id && item.args.price === reward.args.price + ); + if (reward_index > -1) { + allRewardsMerged[reward_index].args.quantity += reward.args.quantity; + allRewardsMerged[reward_index].args.cost += reward.args.cost; + } else { + allRewardsMerged.push(reward); + } + } + }); + let changed = false; + for (const claimedReward of allRewardsMerged) { + if ( + !this._code_activated_coupon_ids.find( + (coupon) => coupon.id === claimedReward.coupon_id + ) && + !this.uiState.couponPointChanges[claimedReward.coupon_id] + ) { + continue; + } + if ( + claimedReward.reward.program_id.program_type === "coupons" && + this.lines.find( + (rewardline) => rewardline.reward_id?.id === claimedReward.reward.id + ) + ) { + continue; + } + if ( + claimedReward.reward.reward_product_ids?.length === 1 && + allRewardsMerged.filter( + (reward) => reward.reward.program_id.id === claimedReward.reward.program_id.id + ).length === 1 + ) { + delete claimedReward.args["quantity"]; + } + this._applyReward(claimedReward.reward, claimedReward.coupon_id, claimedReward.args); + + const newRewardLines = this._get_reward_lines(); + const number_of_line_changed = newRewardLines.length !== rewardLines.length; + const reward_amount_changed = + newRewardLines.reduce((sum, line) => sum + line.qty * line.price_unit, 0) !== + rewardLines.reduce((sum, line) => sum + line.qty * line.price_unit, 0); + if (number_of_line_changed || reward_amount_changed) { + changed = true; + } + } + return changed; + }, +}); diff --git a/static/src/app/screens/product_screen/control_buttons/control_buttons.js b/static/src/app/screens/product_screen/control_buttons/control_buttons.js new file mode 100644 index 0000000..7129611 --- /dev/null +++ b/static/src/app/screens/product_screen/control_buttons/control_buttons.js @@ -0,0 +1,118 @@ +/** @odoo-module **/ + +import { ControlButtons } from "@point_of_sale/app/screens/product_screen/control_buttons/control_buttons"; +import { SelectionPopup } from "@point_of_sale/app/components/popups/selection_popup/selection_popup"; +import { makeAwaitable } from "@point_of_sale/app/utils/make_awaitable_dialog"; +import { patch } from "@web/core/utils/patch"; +import { _t } from "@web/core/l10n/translation"; + +patch(ControlButtons.prototype, { + async _applyReward(reward, coupon_id, potentialQty) { + const order = this.pos.getOrder(); + order.uiState.disabledRewards.delete(reward.id); + + const args = {}; + if (reward.reward_type === "product" && reward.multi_product) { + // Group the reward product ids by their base template (product_tmpl_id) + const templateMap = new Map(); + for (const product of reward.reward_product_ids) { + const template = product.product_tmpl_id || product; + const templateId = template.id; + if (!templateMap.has(templateId)) { + templateMap.set(templateId, { + template: template, + variants: [], + }); + } + templateMap.get(templateId).variants.push(product); + } + + // Create list for SelectionPopup showing base products (templates) + const templatesList = []; + for (const [tmplId, info] of templateMap.entries()) { + templatesList.push({ + id: tmplId, + label: info.template.display_name, + item: info, + }); + } + + // Let user select base product + const selectedChoice = await makeAwaitable(this.dialog, SelectionPopup, { + title: _t("Please select a product for this reward"), + list: templatesList, + }); + if (!selectedChoice) { + return false; + } + + const { template, variants } = selectedChoice; + let finalProduct = null; + + if (variants.length === 1) { + finalProduct = variants[0]; + } else { + // Trigger configurator popup if product has variants + const payload = await this.pos.openConfigurator(template); + if (!payload) { + return false; + } + + const attributeValues = this.models["product.template.attribute.value"] + .readMany(payload.attribute_value_ids) + .filter((value) => value.attribute_id.create_variant !== "no_variant") + .map((value) => value.id); + + finalProduct = variants.find((variant) => { + const attributeIds = (variant.product_template_variant_value_ids || []).map( + (value) => value.id + ); + return ( + attributeValues.every((id) => attributeIds.includes(id)) && + attributeIds.length === attributeValues.length + ); + }); + + if (!finalProduct) { + finalProduct = variants[0]; + } + } + + args["product"] = finalProduct; + } + + // For 'cheapest' applicability rewards, always use _applyReward + // (not addLineToCurrentOrder) so the cheapest product is resolved dynamically. + if (reward.reward_product_applicability === 'cheapest') { + const result = order._applyReward(reward, coupon_id, args); + if (result !== true) { + this.notification.add(result); + } + this.pos.updateRewards(); + return result; + } + + if ( + (reward.reward_type == "product" && reward.program_id.applies_on !== "both") || + (reward.program_id.applies_on == "both" && potentialQty) + ) { + const product = args["product"] || reward.reward_product_ids[0]; + await this.pos.addLineToCurrentOrder( + { + product_id: product, + product_tmpl_id: product.product_tmpl_id, + qty: potentialQty || 1, + }, + {} + ); + return true; + } else { + const result = order._applyReward(reward, coupon_id, args); + if (result !== true) { + this.notification.add(result); + } + this.pos.updateRewards(); + return result; + } + } +}); diff --git a/static/src/app/services/pos_store.js b/static/src/app/services/pos_store.js new file mode 100644 index 0000000..1f7051c --- /dev/null +++ b/static/src/app/services/pos_store.js @@ -0,0 +1,76 @@ +/** @odoo-module **/ + +import { PosStore } from "@point_of_sale/app/services/pos_store"; +import { patch } from "@web/core/utils/patch"; + +patch(PosStore.prototype, { + /** + * Override getPotentialFreeProductRewards to also include rewards + * with reward_product_applicability === 'cheapest'. + * The core iterates reward.reward_product_ids which is empty for + * cheapest rewards that have no fixed product specified, so they + * are never surfaced. + */ + getPotentialFreeProductRewards() { + const result = super.getPotentialFreeProductRewards(); + const order = this.getOrder(); + if (!order) return result; + + const alreadyIncludedRewardIds = new Set(result.map((r) => r.reward.id)); + + const allCouponPrograms = Object.values(order.uiState.couponPointChanges) + .map((pe) => ({ + program_id: pe.program_id, + coupon_id: pe.coupon_id, + })) + .concat( + order._code_activated_coupon_ids.map((coupon) => ({ + program_id: coupon.program_id.id, + coupon_id: coupon.id, + })) + ); + + const hasLine = order.lines.filter((line) => !line.is_reward_line).length > 0; + + for (const couponProgram of allCouponPrograms) { + const program = this.models["loyalty.program"].get(couponProgram.program_id); + if (!program) continue; + if ( + program.pricelist_ids.length > 0 && + (!order.pricelist_id || + !program.pricelist_ids.some((pl) => pl.id === order.pricelist_id.id)) + ) { + continue; + } + + const points = order._getRealCouponPoints(couponProgram.coupon_id); + const considerTheReward = + program.applies_on !== "both" || (program.applies_on === "both" && hasLine); + + for (const reward of program.reward_ids.filter( + (r) => + r.reward_type === "product" && + r.reward_product_applicability === "cheapest" && + !alreadyIncludedRewardIds.has(r.id) + )) { + if (points < reward.required_points) continue; + if (!considerTheReward) continue; + + const cheapest = order._getCheapestProductInOrder(reward); + if (!cheapest) continue; + + const potentialQty = order._computePotentialFreeProductQty(reward, cheapest, points); + if (potentialQty > 0) { + result.push({ + coupon_id: couponProgram.coupon_id, + reward: reward, + potentialQty, + }); + alreadyIncludedRewardIds.add(reward.id); + } + } + } + + return result; + }, +}); diff --git a/views/loyalty_reward_views.xml b/views/loyalty_reward_views.xml new file mode 100644 index 0000000..3ee5f4b --- /dev/null +++ b/views/loyalty_reward_views.xml @@ -0,0 +1,22 @@ + + + + loyalty.reward.view.form.inherit.extend + loyalty.reward + + + + + + + + reward_type == 'product' and reward_product_applicability == 'specific' and not reward_product_ids + reward_type != 'product' or reward_product_applicability == 'cheapest' and reward_product_category_id + + + reward_type == 'product' and reward_product_applicability == 'specific' and not reward_product_ids + reward_type != 'product' or reward_product_applicability == 'cheapest' and reward_product_category_id + + + +