feat: Group 100% loyalty discount reward lines for unified display in the POS UI and receipts while preserving individual lines for accurate accounting.

This commit is contained in:
Suherdy Yacob 2026-02-13 16:48:07 +07:00
parent b381cbc779
commit 98a739b9d3
4 changed files with 92 additions and 5 deletions

View File

@ -10,7 +10,7 @@ This module modifies the loyalty reward discount calculation in POS to apply dis
3. **Proper Accounting Entries**: Accounting entries are created with tax only on the credit side, ensuring compliance with accounting standards.
4. **100% Discount Rounding Fix**: Ensures exact zero-total for 100% discounts by generating individual reward lines for each item, preventing tax rounding discrepancies.
4. **100% Discount Unified Grouping**: Ensures exact zero-total for 100% discounts by generating individual lines (to prevent rounding errors) but displays them as a single aggregated line in both the POS screen and printed receipt.
5. **Zero-Value Journal Entries**: Automatically creates journal entries for orders with a 0.00 total (e.g., 100% discount) to track the "Foregone Income" and "Discount Expense". This is configurable via settings.
@ -19,7 +19,8 @@ This module modifies the loyalty reward discount calculation in POS to apply dis
### JavaScript Changes
- Modified `static/src/overrides/models/loyalty.js` to calculate discounts on tax-exclusive amounts.
- Implemented "Line-by-Line Cancellation" for 100% discounts to fix rounding issues.
- Implemented "Line-by-Line Cancellation" for 100% discounts to fix rounding issues, with UI-level grouping for a clean display.
- Added `Orderline` and `Order` overrides (`static/src/overrides/models/orderline.js`) to handle unified grouping, receipt filtering, and persistence.
### Backend Changes

View File

@ -14,6 +14,7 @@
"assets": {
"point_of_sale._assets_pos": [
'pos_loyalty_discount_before_tax/static/src/overrides/models/loyalty.js',
'pos_loyalty_discount_before_tax/static/src/overrides/models/orderline.js',
]
},
"installable": True,

View File

@ -207,8 +207,9 @@ patch(Order.prototype, {
const discountFactor = totalDiscountableWithoutTax ? Math.min(1, maxDiscount / totalDiscountableWithoutTax) : 1;
if ((1 - discountFactor) < 0.00001) {
// 100% Discount: Generate one reward line per original line to prevent rounding aggregation errors
return formattedLines.map(line => {
// 100% Discount: Generate one reward line per original line to prevent rounding aggregation errors (0.01 issues)
// Tags added for UI-level grouping
return formattedLines.map((line, index) => {
const taxKey = ['ewallet', 'gift_card'].includes(reward.program_id.program_type)
? line.get_taxes().map((t) => t.id)
: line.get_taxes().filter((t) => t.amount_type !== 'fixed').map((t) => t.id);
@ -224,6 +225,10 @@ patch(Order.prototype, {
reward_identifier_code: rewardCode,
tax_ids: taxKey, // Use IDs, not tax objects
merge: false,
is_reward_group_member: true,
reward_group_id: rewardCode,
is_reward_group_head: index === 0,
reward_group_count: formattedLines.length,
};
});
}
@ -259,5 +264,15 @@ patch(Order.prototype, {
result[0]["points_cost"] = pointCost;
}
return result;
},
set_orderline_options(line, options) {
super.set_orderline_options(...arguments);
if (options && options.is_reward_group_member) {
line.is_reward_group_member = options.is_reward_group_member;
line.reward_group_id = options.reward_group_id;
line.is_reward_group_head = options.is_reward_group_head;
line.reward_group_count = options.reward_group_count;
}
},
});

View File

@ -0,0 +1,70 @@
/** @odoo-module **/
import { Order, Orderline } from "@point_of_sale/app/store/models";
import { patch } from "@web/core/utils/patch";
patch(Order.prototype, {
export_for_printing() {
const result = super.export_for_printing(...arguments);
if (result.orderlines) {
result.orderlines = result.orderlines.filter((lineData) => {
const line = this.orderlines.find((l) => l.cid === lineData.cid);
if (line && line.is_reward_group_member && !line.is_reward_group_head) {
return false;
}
return true;
});
}
return result;
},
});
patch(Orderline.prototype, {
getDisplayClasses() {
const classes = super.getDisplayClasses();
if (this.is_reward_group_member && !this.is_reward_group_head) {
classes['d-none'] = true;
}
return classes;
},
getDisplayData() {
const data = super.getDisplayData();
data.cid = this.cid;
if (this.is_reward_group_head) {
// Group and sum all lines in this reward group for display
const groupLines = this.order.get_orderlines().filter(
line => line.reward_group_id === this.reward_group_id
);
let totalDisplayPrice = 0;
let totalQty = 0;
for (const line of groupLines) {
totalDisplayPrice += line.get_display_price();
totalQty += line.get_quantity();
}
data.price = this.env.utils.formatCurrency(totalDisplayPrice, this.pos.currency);
data.qty = totalQty.toString();
}
return data;
},
export_as_JSON() {
const result = super.export_as_JSON(...arguments);
if (this.is_reward_group_member) {
result.is_reward_group_member = this.is_reward_group_member;
result.reward_group_id = this.reward_group_id;
result.is_reward_group_head = this.is_reward_group_head;
result.reward_group_count = this.reward_group_count;
}
return result;
},
init_from_JSON(json) {
if (json.is_reward_group_member) {
this.is_reward_group_member = json.is_reward_group_member;
this.reward_group_id = json.reward_group_id;
this.is_reward_group_head = json.is_reward_group_head;
this.reward_group_count = json.reward_group_count;
}
super.init_from_JSON(...arguments);
}
});