first commit

This commit is contained in:
Suherdy Yacob 2026-05-28 14:50:21 +07:00
commit 203d3035d1
11 changed files with 1000 additions and 0 deletions

68
.gitignore vendored Normal file
View File

@ -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

132
README.md Normal file
View File

@ -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

2
__init__.py Normal file
View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models

22
__manifest__.py Normal file
View File

@ -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',
}

3
models/__init__.py Normal file
View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import loyalty_reward
from . import sale_order

63
models/loyalty_reward.py Normal file
View File

@ -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,
]),
])

65
models/sale_order.py Normal file
View File

@ -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],
}]

View File

@ -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;
},
});

View File

@ -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;
}
}
});

View File

@ -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;
},
});

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_reward_view_form_inherit_extend" model="ir.ui.view">
<field name="name">loyalty.reward.view.form.inherit.extend</field>
<field name="model">loyalty.reward</field>
<field name="inherit_id" ref="loyalty.loyalty_reward_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='reward_product_id']/parent::group" position="inside">
<field name="reward_product_applicability" widget="radio"/>
<field name="reward_product_category_id" invisible="reward_product_applicability != 'cheapest'"/>
</xpath>
<xpath expr="//field[@name='reward_product_id']" position="attributes">
<attribute name="required">reward_type == 'product' and reward_product_applicability == 'specific' and not reward_product_ids</attribute>
<attribute name="invisible">reward_type != 'product' or reward_product_applicability == 'cheapest' and reward_product_category_id</attribute>
</xpath>
<xpath expr="//field[@name='reward_product_tag_id']" position="attributes">
<attribute name="required">reward_type == 'product' and reward_product_applicability == 'specific' and not reward_product_ids</attribute>
<attribute name="invisible">reward_type != 'product' or reward_product_applicability == 'cheapest' and reward_product_category_id</attribute>
</xpath>
</field>
</record>
</odoo>