From 8d756a0e6f25885691de1e17c67ea042d83a0d99 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Thu, 12 Mar 2026 08:58:58 +0700 Subject: [PATCH] feat: Adjust loyalty discount calculations to apply before tax and introduce accounting for 100% loyalty discounts. --- __init__.py | 3 +- __manifest__.py | 3 +- models/__init__.py | 3 + models/pos_config.py | 17 + models/pos_session.py | 74 ++++ models/res_config_settings.py | 13 + static/src/overrides/models/loyalty.js | 500 +++++++++++++---------- static/src/overrides/models/orderline.js | 76 ++++ views/res_config_settings_views.xml | 22 + 9 files changed, 487 insertions(+), 224 deletions(-) create mode 100644 models/__init__.py create mode 100644 models/pos_config.py create mode 100644 models/pos_session.py create mode 100644 models/res_config_settings.py create mode 100644 static/src/overrides/models/orderline.js create mode 100644 views/res_config_settings_views.xml diff --git a/__init__.py b/__init__.py index 7c68785..f5ba686 100755 --- a/__init__.py +++ b/__init__.py @@ -1 +1,2 @@ -# -*- coding: utf-8 -*- \ No newline at end of file +# -*- coding: utf-8 -*- +from . import models \ No newline at end of file diff --git a/__manifest__.py b/__manifest__.py index f360004..ff9f367 100755 --- a/__manifest__.py +++ b/__manifest__.py @@ -9,11 +9,12 @@ """, "depends": ["point_of_sale", "pos_loyalty"], "data": [ - + "views/res_config_settings_views.xml", ], "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/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..55380fe --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,3 @@ +from . import pos_config +from . import pos_session +from . import res_config_settings diff --git a/models/pos_config.py b/models/pos_config.py new file mode 100644 index 0000000..3d2c4c0 --- /dev/null +++ b/models/pos_config.py @@ -0,0 +1,17 @@ +from odoo import fields, models + +class PosConfig(models.Model): + _inherit = 'pos.config' + + discount_100_income_account_id = fields.Many2one( + 'account.account', + string='100% Discount Income Account', + help='Account used for Income when an order has a 100% discount (0 total).', + domain="[('deprecated', '=', False)]" + ) + discount_100_expense_account_id = fields.Many2one( + 'account.account', + string='100% Discount Expense Account', + help='Account used for Expense/Discount when an order has a 100% discount (0 total).', + domain="[('deprecated', '=', False)]" + ) diff --git a/models/pos_session.py b/models/pos_session.py new file mode 100644 index 0000000..19ae057 --- /dev/null +++ b/models/pos_session.py @@ -0,0 +1,74 @@ +from odoo import models, _ +from odoo.tools import float_is_zero + +class PosSession(models.Model): + _inherit = 'pos.session' + + def _create_account_move(self, balancing_account=False, amount_to_balance=0, bank_payment_method_diffs=None): + """ + Extend _create_account_move to generate additional journal entry lines + for 100% discount orders (where total amount is 0). + """ + # Call super to generate the standard move + data = super(PosSession, self)._create_account_move(balancing_account, amount_to_balance, bank_payment_method_diffs) + + if not self.config_id.discount_100_income_account_id or not self.config_id.discount_100_expense_account_id: + return data + + # Identify orders with 0 absolute paid amount but non-zero gross amount + # We look for orders where amount_total is near zero. + # Note: 100% discount orders have amount_total = 0. + + MoveLine = data.get('MoveLine') + if not MoveLine: + # Depending on Odoo 19's internal structure changes, fallback / ensure MoveLine exists. Odoo 17 returns a dict with 'MoveLine'. + MoveLine = self.env['account.move.line'] + + income_account = self.config_id.discount_100_income_account_id + expense_account = self.config_id.discount_100_expense_account_id + + # Helper to convert amount to company currency if needed, similar to Odoo's internals + def _get_amounts(amount, date): + return self._update_amounts({'amount': 0, 'amount_converted': 0}, {'amount': amount}, date) + + zero_value_moves = [] + + for order in self._get_closed_orders(): + if float_is_zero(order.amount_total, precision_rounding=self.currency_id.rounding): + # Calculate the exact discount amount applied by loyalty rewards + discount_amount = sum( + abs(line.price_subtotal) + for line in order.lines + if line.price_subtotal < 0 and (getattr(line, 'is_reward_line', False) or getattr(line, 'reward_id', False)) + ) + + if float_is_zero(discount_amount, precision_rounding=self.currency_id.rounding): + continue + + amounts = _get_amounts(discount_amount, order.date_order) + + # Create Credit Line (Income) + # We use _credit_amounts helper logic style manually + credit_vals = { + 'name': _('100%% Discount Income: %s') % order.name, + 'account_id': income_account.id, + 'move_id': self.move_id.id, + 'partner_id': order.partner_id.id or False, + } + credit_vals.update(self._credit_amounts(credit_vals, amounts['amount'], amounts['amount_converted'])) + zero_value_moves.append(credit_vals) + + # Create Debit Line (Expense/Discount) + debit_vals = { + 'name': _('100%% Discount Expense: %s') % order.name, + 'account_id': expense_account.id, + 'move_id': self.move_id.id, + 'partner_id': order.partner_id.id or False, + } + debit_vals.update(self._debit_amounts(debit_vals, amounts['amount'], amounts['amount_converted'])) + zero_value_moves.append(debit_vals) + + if zero_value_moves and self.move_id: + MoveLine.with_context(check_move_validity=False).create(zero_value_moves) + + return data diff --git a/models/res_config_settings.py b/models/res_config_settings.py new file mode 100644 index 0000000..8765b2e --- /dev/null +++ b/models/res_config_settings.py @@ -0,0 +1,13 @@ +from odoo import fields, models + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + pos_discount_100_income_account_id = fields.Many2one( + related='pos_config_id.discount_100_income_account_id', + readonly=False, + ) + pos_discount_100_expense_account_id = fields.Many2one( + related='pos_config_id.discount_100_expense_account_id', + readonly=False, + ) diff --git a/static/src/overrides/models/loyalty.js b/static/src/overrides/models/loyalty.js index ed33156..b35f47f 100755 --- a/static/src/overrides/models/loyalty.js +++ b/static/src/overrides/models/loyalty.js @@ -1,222 +1,278 @@ -/** @odoo-module **/ - -import { Order } from "@point_of_sale/app/store/models"; -import { patch } from "@web/core/utils/patch"; -import { roundPrecision } from "@web/core/utils/numbers"; - -// Patch Order methods to handle all loyalty discounts with discount_before_tax -patch(Order.prototype, { - /** - * Override to calculate discountable amount without tax for all reward types - */ - _getDiscountableOnOrder(reward) { - // For all rewards, we calculate discounts without tax - let discountable = 0; - const discountablePerTax = {}; - const orderLines = this.get_orderlines(); - - for (const line of orderLines) { - if (!line.get_quantity()) { - continue; - } - - // Skip reward lines to avoid circular discounts (unless specifically allowed) - if (line.reward_id || line.is_reward_line) { - continue; - } - - // Use price without tax for discount calculation - const line_total_without_tax = line.get_price_without_tax(); - - 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); - - discountable += line_total_without_tax; - if (!discountablePerTax[taxKey]) { - discountablePerTax[taxKey] = 0; - } - discountablePerTax[taxKey] += line_total_without_tax; - } - return { discountable, discountablePerTax }; - }, - - /** - * Override to calculate cheapest line discountable without tax for all reward types - */ - _getDiscountableOnCheapest(reward) { - // For all rewards, we calculate discounts without tax - const cheapestLine = this._getCheapestLine(); - if (!cheapestLine) { - return { discountable: 0, discountablePerTax: {} }; - } - - // Use price without tax for discount calculation - const discountableWithoutTax = cheapestLine.get_price_without_tax(); - const taxKey = cheapestLine.get_taxes().map((t) => t.id); - - return { - discountable: discountableWithoutTax, - discountablePerTax: Object.fromEntries([[taxKey, discountableWithoutTax]]), - }; - }, - - /** - * Override to calculate specific product discountable without tax for all reward types - */ - _getDiscountableOnSpecific(reward) { - // For all rewards, we calculate discounts without tax - const applicableProducts = reward.all_discount_product_ids; - const linesToDiscount = []; - const discountLinesPerReward = {}; - const orderLines = this.get_orderlines(); - const remainingAmountPerLine = {}; - - for (const line of orderLines) { - if (!line.get_quantity() || !line.price) { - continue; - } - const product_id = line.get_product().id; - remainingAmountPerLine[line.cid] = line.get_price_without_tax(); - - let included = false; - - if ( - applicableProducts.has(product_id) || - (line.reward_product_id && applicableProducts.has(line.reward_product_id)) - ) { - linesToDiscount.push(line); - included = true; - } else if (line.reward_id) { - const lineReward = this.pos.reward_by_id[line.reward_id]; - if (lineReward.id === reward.id || - ( - orderLines.some(product => - lineReward.all_discount_product_ids.has(product.get_product().id) && - applicableProducts.has(product.get_product().id) - ) && - lineReward.reward_type === 'discount' && - lineReward.discount_mode != 'percent' - ) - ) { - linesToDiscount.push(line); - included = true; - } - if (!discountLinesPerReward[line.reward_identifier_code]) { - discountLinesPerReward[line.reward_identifier_code] = []; - } - discountLinesPerReward[line.reward_identifier_code].push(line); - } - } - - let discountable = 0; - const discountablePerTax = {}; - for (const line of linesToDiscount) { - discountable += remainingAmountPerLine[line.cid]; - const taxKey = line.get_taxes().map((t) => t.id); - if (!discountablePerTax[taxKey]) { - discountablePerTax[taxKey] = 0; - } - discountablePerTax[taxKey] += - remainingAmountPerLine[line.cid]; - } - return { discountable, discountablePerTax }; - }, - - /** - * Override reward line values creation to handle discount calculation without tax for all rewards - */ - _getRewardLineValuesDiscount(args) { - const reward = args["reward"]; - const coupon_id = args["coupon_id"]; - - // For all rewards, we calculate discounts without tax - const rewardAppliesTo = reward.discount_applicability; - - let getDiscountable; - if (rewardAppliesTo === "order") { - getDiscountable = this._getDiscountableOnOrder.bind(this); - } else if (rewardAppliesTo === "cheapest") { - getDiscountable = this._getDiscountableOnCheapest.bind(this); - } else if (rewardAppliesTo === "specific") { - getDiscountable = this._getDiscountableOnSpecific.bind(this); - } - if (!getDiscountable) { - return "Unknown discount type"; - } - - let { discountable, discountablePerTax } = getDiscountable(reward); - // For all rewards, we should use total without tax for comparison - const totalForComparison = this.get_total_without_tax(); - discountable = Math.min(totalForComparison, discountable); - if (!discountable) { - return []; - } - - let maxDiscount = reward.discount_max_amount || Infinity; - if (reward.discount_mode === "per_point") { - const points = (["ewallet", "gift_card"].includes(reward.program_id.program_type)) ? - this._getRealCouponPoints(coupon_id) : - Math.floor(this._getRealCouponPoints(coupon_id) / reward.required_points) * reward.required_points; - maxDiscount = Math.min( - maxDiscount, - roundPrecision(reward.discount * points, this.pos.currency.rounding) - ); - } else if (reward.discount_mode === "per_order") { - maxDiscount = Math.min(maxDiscount, reward.discount); - } else if (reward.discount_mode === "percent") { - maxDiscount = Math.min(maxDiscount, roundPrecision(discountable * (reward.discount / 100), this.pos.currency.rounding)); - } - - const rewardCode = Math.random().toString(36).substring(3); - let pointCost = reward.clear_wallet - ? this._getRealCouponPoints(coupon_id) - : reward.required_points; - - if (reward.discount_mode === "per_point" && !reward.clear_wallet) { - pointCost = roundPrecision(Math.min(maxDiscount, discountable) / reward.discount, this.pos.currency.rounding); - } - - // Apply rounding to pointCost if it's calculated from division - if (pointCost && typeof pointCost === 'number') { - pointCost = roundPrecision(pointCost, this.pos.currency.rounding); - } - - // For all rewards, we calculate discount on price without tax - // Calculate the total discountable amount without tax - let totalDiscountableWithoutTax = 0; - for (const [, amount] of Object.entries(discountablePerTax)) { - totalDiscountableWithoutTax += amount; - } - - const discountFactor = totalDiscountableWithoutTax ? Math.min(1, maxDiscount / totalDiscountableWithoutTax) : 1; - - const result = Object.entries(discountablePerTax).reduce((lst, entry) => { - if (!entry[1]) { - return lst; - } - const taxIds = entry[0] === "" ? [] : entry[0].split(",").map((str) => parseInt(str)); - // Calculate tax-exclusive discount value for display - const preliminaryAmount = entry[1] * discountFactor; - const discountAmount = roundPrecision(preliminaryAmount, this.pos.currency.rounding); - lst.push({ - product: reward.discount_line_product_id, - price: -discountAmount, - quantity: 1, - reward_id: reward.id, - is_reward_line: true, - coupon_id: coupon_id, - points_cost: 0, - reward_identifier_code: rewardCode, - tax_ids: taxIds, - merge: false, - }); - return lst; - }, []); - - if (result.length) { - result[0]["points_cost"] = pointCost; - } - return result; - } -}); +/** @odoo-module **/ + +import { Order } from "@point_of_sale/app/store/models"; +import { patch } from "@web/core/utils/patch"; +import { roundPrecision } from "@web/core/utils/numbers"; + +// Patch Order methods to handle all loyalty discounts with discount_before_tax +patch(Order.prototype, { + /** + * Override to calculate discountable amount without tax for all reward types + */ + _getDiscountableOnOrder(reward) { + // For all rewards, we calculate discounts without tax + let discountable = 0; + const discountablePerTax = {}; + const discountableWithTaxPerTax = {}; + const formattedLines = []; + const orderLines = this.get_orderlines(); + + for (const line of orderLines) { + if (!line.get_quantity()) { + continue; + } + + // Skip reward lines to avoid circular discounts (unless specifically allowed) + if (line.reward_id || line.is_reward_line) { + continue; + } + + // Use price without tax for discount calculation + const line_total_without_tax = line.get_price_without_tax(); + + 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); + + discountable += line_total_without_tax; + if (!discountablePerTax[taxKey]) { + discountablePerTax[taxKey] = 0; + discountableWithTaxPerTax[taxKey] = 0; + } + discountablePerTax[taxKey] += line_total_without_tax; + discountableWithTaxPerTax[taxKey] += line.get_price_with_tax(); + formattedLines.push(line); + } + return { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines }; + }, + + /** + * Override to calculate cheapest line discountable without tax for all reward types + */ + _getDiscountableOnCheapest(reward) { + // For all rewards, we calculate discounts without tax + const cheapestLine = this._getCheapestLine(); + if (!cheapestLine) { + return { discountable: 0, discountablePerTax: {} }; + } + + // Use price without tax for discount calculation + const discountableWithoutTax = cheapestLine.get_price_without_tax(); + const discountableWithTax = cheapestLine.get_price_with_tax(); + const taxKey = cheapestLine.get_taxes().map((t) => t.id); + + return { + discountable: discountableWithoutTax, + discountablePerTax: Object.fromEntries([[taxKey, discountableWithoutTax]]), + discountableWithTaxPerTax: Object.fromEntries([[taxKey, discountableWithTax]]), + formattedLines: [cheapestLine], + }; + }, + + /** + * Override to calculate specific product discountable without tax for all reward types + */ + _getDiscountableOnSpecific(reward) { + // For all rewards, we calculate discounts without tax + const applicableProducts = reward.all_discount_product_ids; + const linesToDiscount = []; + const discountLinesPerReward = {}; + const orderLines = this.get_orderlines(); + const remainingAmountPerLine = {}; + const remainingAmountWithTaxPerLine = {}; + + for (const line of orderLines) { + if (!line.get_quantity() || !line.price) { + continue; + } + + const product_id = line.get_product().id; + remainingAmountPerLine[line.cid] = line.get_price_without_tax(); + remainingAmountWithTaxPerLine[line.cid] = line.get_price_with_tax(); + + let included = false; + + if ( + applicableProducts.has(product_id) || + (line.reward_product_id && applicableProducts.has(line.reward_product_id)) + ) { + linesToDiscount.push(line); + included = true; + } else if (line.reward_id) { + const lineReward = this.pos.reward_by_id[line.reward_id]; + if (lineReward.id === reward.id || + ( + orderLines.some(product => + lineReward.all_discount_product_ids.has(product.get_product().id) && + applicableProducts.has(product.get_product().id) + ) && + lineReward.reward_type === 'discount' && + lineReward.discount_mode != 'percent' + ) + ) { + linesToDiscount.push(line); + included = true; + } + if (!discountLinesPerReward[line.reward_identifier_code]) { + discountLinesPerReward[line.reward_identifier_code] = []; + } + discountLinesPerReward[line.reward_identifier_code].push(line); + } + } + + let discountable = 0; + const discountablePerTax = {}; + const discountableWithTaxPerTax = {}; + for (const line of linesToDiscount) { + discountable += remainingAmountPerLine[line.cid]; + const taxKey = line.get_taxes().map((t) => t.id); + if (!discountablePerTax[taxKey]) { + discountablePerTax[taxKey] = 0; + discountableWithTaxPerTax[taxKey] = 0; + } + discountablePerTax[taxKey] += + remainingAmountPerLine[line.cid]; + discountableWithTaxPerTax[taxKey] += + remainingAmountWithTaxPerLine[line.cid]; + } + return { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines: linesToDiscount }; + }, + + /** + * Override reward line values creation to handle discount calculation without tax for all rewards + */ + _getRewardLineValuesDiscount(args) { + const reward = args["reward"]; + const coupon_id = args["coupon_id"]; + + // For all rewards, we calculate discounts without tax + const rewardAppliesTo = reward.discount_applicability; + + let getDiscountable; + if (rewardAppliesTo === "order") { + getDiscountable = this._getDiscountableOnOrder.bind(this); + } else if (rewardAppliesTo === "cheapest") { + getDiscountable = this._getDiscountableOnCheapest.bind(this); + } else if (rewardAppliesTo === "specific") { + getDiscountable = this._getDiscountableOnSpecific.bind(this); + } + if (!getDiscountable) { + return "Unknown discount type"; + } + + let { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines } = getDiscountable(reward); + // For all rewards, we should use total without tax for comparison + const totalForComparison = this.get_total_without_tax(); + discountable = Math.min(totalForComparison, discountable); + if (!discountable) { + return []; + } + + let maxDiscount = reward.discount_max_amount || Infinity; + if (reward.discount_mode === "per_point") { + const points = (["ewallet", "gift_card"].includes(reward.program_id.program_type)) ? + this._getRealCouponPoints(coupon_id) : + Math.floor(this._getRealCouponPoints(coupon_id) / reward.required_points) * reward.required_points; + maxDiscount = Math.min( + maxDiscount, + roundPrecision(reward.discount * points, this.pos.currency.rounding) + ); + } else if (reward.discount_mode === "per_order") { + maxDiscount = Math.min(maxDiscount, reward.discount); + } else if (reward.discount_mode === "percent") { + maxDiscount = Math.min(maxDiscount, roundPrecision(discountable * (reward.discount / 100), this.pos.currency.rounding)); + } + + const rewardCode = Math.random().toString(36).substring(3); + let pointCost = reward.clear_wallet + ? this._getRealCouponPoints(coupon_id) + : reward.required_points; + + if (reward.discount_mode === "per_point" && !reward.clear_wallet) { + pointCost = roundPrecision(Math.min(maxDiscount, discountable) / reward.discount, this.pos.currency.rounding); + } + + // Apply rounding to pointCost if it's calculated from division + if (pointCost && typeof pointCost === 'number') { + pointCost = roundPrecision(pointCost, this.pos.currency.rounding); + } + + // For all rewards, we calculate discount on price without tax + // Calculate the total discountable amount without tax + let totalDiscountableWithoutTax = 0; + for (const [, amount] of Object.entries(discountablePerTax)) { + totalDiscountableWithoutTax += amount; + } + + 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 (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); + + return { + product: reward.discount_line_product_id, + price: -line.get_price_without_tax(), + quantity: 1, + reward_id: reward.id, + is_reward_line: true, + coupon_id: coupon_id, + points_cost: 0, + 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, + }; + }); + } + + const result = Object.entries(discountablePerTax).reduce((lst, entry) => { + if (!entry[1]) { + return lst; + } + const taxIds = entry[0] === "" ? [] : entry[0].split(",").map((str) => parseInt(str)); + // Calculate tax-exclusive discount value for display + let preliminaryAmount = entry[1] * discountFactor; + + // Back-calculate logic removed as line-by-line strategy handles 100% case. + // Keeping partial discount logic standard for now. + + const discountAmount = roundPrecision(preliminaryAmount, this.pos.currency.rounding); + lst.push({ + product: reward.discount_line_product_id, + price: -discountAmount, + quantity: 1, + reward_id: reward.id, + is_reward_line: true, + coupon_id: coupon_id, + points_cost: 0, + reward_identifier_code: rewardCode, + tax_ids: taxIds, + merge: false, + }); + return lst; + }, []); + + if (result.length) { + 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..72e3d57 --- /dev/null +++ b/static/src/overrides/models/orderline.js @@ -0,0 +1,76 @@ +/** @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() { + // Need to ensure we call the original function, might not exist in some versions or might be different + let classes = {}; + if (typeof super.getDisplayClasses === 'function') { + 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(); + } + + // this.env might not exist in Odoo 19 this way, we'll try to use this.pos.env if this.env is absent. + const env = this.env || this.pos.env; + data.price = 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); + } +}); diff --git a/views/res_config_settings_views.xml b/views/res_config_settings_views.xml new file mode 100644 index 0000000..638194a --- /dev/null +++ b/views/res_config_settings_views.xml @@ -0,0 +1,22 @@ + + + + res.config.settings.view.form.inherit.pos.loyalty.discount.100 + res.config.settings + + + + +
+
+
+
+
+
+
+
+