From 98a739b9d33f3532f618cea5d053f26e8ffbbb76 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Fri, 13 Feb 2026 16:48:07 +0700 Subject: [PATCH] feat: Group 100% loyalty discount reward lines for unified display in the POS UI and receipts while preserving individual lines for accurate accounting. --- README.md | 5 +- __manifest__.py | 1 + static/src/overrides/models/loyalty.js | 21 ++++++- static/src/overrides/models/orderline.js | 70 ++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 static/src/overrides/models/orderline.js diff --git a/README.md b/README.md index 66d2dc6..0874be4 100755 --- a/README.md +++ b/README.md @@ -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 diff --git a/__manifest__.py b/__manifest__.py index fd9104c..80ed1e6 100755 --- a/__manifest__.py +++ b/__manifest__.py @@ -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, diff --git a/static/src/overrides/models/loyalty.js b/static/src/overrides/models/loyalty.js index c417dca..4fdb860 100755 --- a/static/src/overrides/models/loyalty.js +++ b/static/src/overrides/models/loyalty.js @@ -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; + } + }, }); diff --git a/static/src/overrides/models/orderline.js b/static/src/overrides/models/orderline.js new file mode 100644 index 0000000..a524612 --- /dev/null +++ b/static/src/overrides/models/orderline.js @@ -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); + } +});