first commit
This commit is contained in:
commit
203d3035d1
68
.gitignore
vendored
Normal file
68
.gitignore
vendored
Normal 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
132
README.md
Normal 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
2
__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
22
__manifest__.py
Normal file
22
__manifest__.py
Normal 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
3
models/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import loyalty_reward
|
||||
from . import sale_order
|
||||
63
models/loyalty_reward.py
Normal file
63
models/loyalty_reward.py
Normal 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
65
models/sale_order.py
Normal 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],
|
||||
}]
|
||||
429
static/src/app/models/pos_order.js
Normal file
429
static/src/app/models/pos_order.js
Normal 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;
|
||||
},
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
76
static/src/app/services/pos_store.js
Normal file
76
static/src/app/services/pos_store.js
Normal 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;
|
||||
},
|
||||
});
|
||||
22
views/loyalty_reward_views.xml
Normal file
22
views/loyalty_reward_views.xml
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user