commit 658bb6c72a2aeec0047d650269c7515219d62efb Author: Suherdy SYC. Yacob Date: Tue Sep 2 15:25:52 2025 +0700 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5851a2 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# POS Order Line Discount + +This module converts order-level discounts (like global discount and loyalty program reward discount) to order line discounts in the Point of Sale. + +## Features + +- Convert order-level discounts to line-level discounts +- Support for loyalty program rewards +- Configurable discount distribution methods +- Maintain proper tax calculations + +## Configuration + +1. Go to Point of Sale > Configuration > Point of Sale +2. Select your POS configuration +3. In the Pricing section, you'll find: + - **Line Discount Type**: Choose between percentage or fixed amount distribution + - **Apply Line Discount on Rewards**: Enable to convert loyalty rewards to line discounts + +## How it works + +The module works by intercepting the reward application process and distributing the discount amount across all order lines instead of creating a separate reward line. This ensures that: + +1. All discounts are visible at the line level +2. Tax calculations are properly maintained +3. Reporting shows discounts at the line level + +## Technical Details + +The module patches the following components: + +1. **PosOrder model**: Processes rewards as line discounts during order creation +2. **PosConfig model**: Adds configuration options for discount distribution +3. **Order model (JS)**: Overrides reward application to distribute discounts to lines +4. **Orderline model (JS)**: Enhances line discount handling + +## Compatibility + +This module is compatible with Odoo 17 and requires: +- point_of_sale +- pos_loyalty + +## Installation + +1. Place the module in your Odoo addons directory +2. Update the apps list +3. Install the "POS Order Line Discount" module +4. Configure your POS settings as needed diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..cde864b --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..329c273 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +{ + "name": "POS Order Line Discount", + "version": "1.0", + "category": "Point of Sale", + "summary": "Convert order-level discounts to line-level discounts in POS", + "description": """ + This module converts order-level discounts (like global discount and loyalty program reward discount) + to order line discounts in the Point of Sale. + """, + "depends": ["point_of_sale", "pos_loyalty"], + "data": [ + "views/pos_config_views.xml", + ], + "assets": { + "point_of_sale._assets_pos": [ + 'pos_order_line_discount/static/src/**/*', + ], + }, + "installable": True, + "auto_install": False, + "license": "LGPL-3" +} diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..dcdcfb5 Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e27bb3d --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import pos_order +from . import pos_config diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..0d392b2 Binary files /dev/null and b/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__pycache__/pos_config.cpython-312.pyc b/models/__pycache__/pos_config.cpython-312.pyc new file mode 100644 index 0000000..ebaf388 Binary files /dev/null and b/models/__pycache__/pos_config.cpython-312.pyc differ diff --git a/models/__pycache__/pos_order.cpython-312.pyc b/models/__pycache__/pos_order.cpython-312.pyc new file mode 100644 index 0000000..d653af4 Binary files /dev/null and b/models/__pycache__/pos_order.cpython-312.pyc differ diff --git a/models/pos_config.py b/models/pos_config.py new file mode 100644 index 0000000..17f895c --- /dev/null +++ b/models/pos_config.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields + + +class PosConfig(models.Model): + _inherit = 'pos.config' + + line_discount_type = fields.Selection([ + ('percentage', 'Percentage'), + ('fixed', 'Fixed Amount') + ], string='Line Discount Type', default='percentage', + help="Determines how order-level discounts are distributed to order lines") + + apply_line_discount_on_rewards = fields.Boolean( + string="Apply Line Discount on Rewards", + default=True, + help="If checked, loyalty rewards will be applied as line discounts instead of order-level discounts" + ) diff --git a/models/pos_order.py b/models/pos_order.py new file mode 100644 index 0000000..823502a --- /dev/null +++ b/models/pos_order.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +from odoo import models, api, fields +from odoo.tools import float_compare, float_round +import logging + +_logger = logging.getLogger(__name__) + + +class PosOrder(models.Model): + _inherit = 'pos.order' + + def _distribute_discount_to_lines(self, order, discount_amount, is_percentage=False): + """ + Distribute an order-level discount amount to individual order lines + :param order: pos.order record + :param discount_amount: The discount amount to distribute + :param is_percentage: If True, discount_amount is a percentage; otherwise it's a fixed amount + :return: Dictionary with line_id as key and discount value as value + """ + if not order.lines: + return {} + + # Filter out reward lines and lines with zero price + regular_lines = order.lines.filtered(lambda l: not l.is_reward_line and l.price_subtotal > 0) + if not regular_lines: + return {} + + # Calculate total tax-exclusive amount for proportional distribution + total_amount = sum(line.price_subtotal for line in regular_lines) + if total_amount <= 0: + return {} + + line_discounts = {} + + if is_percentage: + # Apply the same percentage discount to all regular lines + for line in regular_lines: + # For percentage discounts, we simply add the percentages (simplified approach) + # In a real scenario, you might want to compound them properly + new_discount = min(100, line.discount + discount_amount) + line_discounts[line.id] = new_discount + else: + # Distribute fixed amount proportionally based on line tax-exclusive subtotal + remaining_discount = discount_amount + lines_count = len(regular_lines) + + for i, line in enumerate(regular_lines): + if i == lines_count - 1: + # Last line gets the remaining discount to avoid rounding issues + # Calculate the additional discount percentage for this line based on tax-exclusive price + if line.price_subtotal > 0: + additional_discount_percentage = (remaining_discount / line.price_subtotal) * 100 + new_discount = min(100, line.discount + additional_discount_percentage) + else: + new_discount = line.discount + line_discounts[line.id] = new_discount + else: + # Calculate proportional discount for this line based on tax-exclusive price + line_ratio = line.price_subtotal / total_amount if total_amount > 0 else 0 + line_discount_amount = discount_amount * line_ratio + # Calculate the additional discount percentage for this line based on tax-exclusive price + if line.price_subtotal > 0: + additional_discount_percentage = (line_discount_amount / line.price_subtotal) * 100 + new_discount = min(100, line.discount + additional_discount_percentage) + else: + new_discount = line.discount + line_discounts[line.id] = new_discount + remaining_discount -= line_discount_amount + + return line_discounts + + def _apply_line_discounts(self, order, line_discounts): + """ + Apply calculated discounts to order lines + :param order: pos.order record + :param line_discounts: Dictionary with line_id as key and discount value as value + """ + for line in order.lines: + if line.id in line_discounts: + # Apply the calculated discount + line.discount = line_discounts[line.id] + # Trigger the onchange to recalculate the line amounts + line._onchange_amount_line_all() + + @api.model + def _process_order_rewards_as_line_discounts(self, order): + """ + Process loyalty rewards as line discounts instead of order-level discounts + :param order: pos.order record + """ + if not order.config_id.apply_line_discount_on_rewards: + return + + # Find reward lines + reward_lines = order.lines.filtered(lambda l: l.is_reward_line) + + for reward_line in reward_lines: + reward = reward_line.reward_id + if reward and reward.reward_type == 'discount': + # Calculate the discount amount that should be applied to regular lines + reward_amount = abs(reward_line.price_subtotal) + + # Remove the reward line + reward_line.unlink() + + # Distribute the discount to regular lines + is_percentage = (reward.discount_mode == 'percent') + discount_value = reward_amount if not is_percentage else reward.discount + + line_discounts = self._distribute_discount_to_lines( + order, + discount_value, + is_percentage=is_percentage + ) + + # Apply the discounts to lines + self._apply_line_discounts(order, line_discounts) + + @api.model + def _process_order(self, order, draft, existing_order): + """ + Override to process rewards as line discounts + """ + pos_order_id = super(PosOrder, self)._process_order(order, draft, existing_order) + + # Convert order-level rewards to line discounts + if isinstance(pos_order_id, int): + pos_order = self.browse(pos_order_id) + self._process_order_rewards_as_line_discounts(pos_order) + + return pos_order_id + + +class PosOrderLine(models.Model): + _inherit = 'pos.order.line' + + def _compute_amount_line_all(self): + """ + Override to handle line-level discounts properly + """ + res = super(PosOrderLine, self)._compute_amount_line_all() + # Additional logic for line discounts can be added here if needed + return res + + @api.onchange('discount') + def _onchange_discount(self): + """ + Override to handle line discount changes + """ + super(PosOrderLine, self)._onchange_discount() + # Additional logic for handling line discount changes can be added here diff --git a/static/src/models.js b/static/src/models.js new file mode 100644 index 0000000..41bb16c --- /dev/null +++ b/static/src/models.js @@ -0,0 +1,199 @@ +/** @odoo-module **/ + +import { Order, Orderline } from "@point_of_sale/app/store/models"; +import { patch } from "@web/core/utils/patch"; + +// Patch Order methods to handle line discounts +patch(Order.prototype, { + /** + * Override to calculate reward values as line discounts instead of order-level discounts + */ + _getRewardLineValues(args) { + const reward = args["reward"]; + const coupon_id = args["coupon_id"]; + + // If config is not set to apply rewards as line discounts, use the original method + // Note: In JavaScript, we access the config through this.pos.config + if (!this.pos.config.apply_line_discount_on_rewards) { + return super._getRewardLineValues(...arguments); + } + + // For discount rewards, we'll distribute the discount to order lines instead of creating reward lines + if (reward.reward_type === "discount") { + // Calculate the reward using the original method first to get the discount amount + const originalRewardLines = super._getRewardLineValues(...arguments); + + // Get the total discount amount from the reward lines (tax-excluded) + let totalDiscountAmount = 0; + originalRewardLines.forEach(line => { + // Calculate tax-excluded amount from the reward line + // The line.price is tax-included, so we need to calculate the tax-excluded amount + // Reward lines are plain objects, not Orderline instances, so we calculate manually + let taxExcludedAmount = Math.abs(line.price * line.quantity); + + // If the line has tax information, we need to calculate the tax-excluded amount + if (line.tax_ids && line.tax_ids.length > 0) { + // Calculate total tax rate (simplified approach for multiple taxes) + let totalTaxRate = 0; + for (const taxId of line.tax_ids) { + const tax = this.pos.taxes_by_id[taxId]; + if (tax && tax.amount_type === 'percent') { + totalTaxRate += tax.amount / 100; + } + } + + if (totalTaxRate > 0) { + taxExcludedAmount = Math.abs(line.price * line.quantity) / (1 + totalTaxRate); + } + } + + totalDiscountAmount += taxExcludedAmount; + }); + + // Distribute the discount to order lines instead of creating reward lines + // Filter out reward lines by checking if they have a reward_id property + const orderLines = this.get_orderlines().filter(line => !line.is_reward_line); + const totalOrderAmount = orderLines.reduce((sum, line) => sum + line.get_price_without_tax(), 0); + + if (totalOrderAmount > 0 && totalDiscountAmount > 0) { + // Apply the discount to each line proportionally based on its contribution to the total + orderLines.forEach(line => { + // Calculate the line's share of the total discount + const lineRatio = line.get_price_without_tax() / totalOrderAmount; + const lineDiscountAmount = totalDiscountAmount * lineRatio; + + // Calculate the discount percentage for this line + // We want to set a discount that results in the lineDiscountAmount reduction + if (line.get_price_without_tax() > 0) { + const lineDiscountPercentage = (lineDiscountAmount / line.get_price_without_tax()) * 100; + // Set the discount percentage for this line (same as %Disc button) + // Ensure the discount percentage is within valid range [0, 100] + const validDiscountPercentage = Math.min(100, Math.max(0, lineDiscountPercentage)); + // Mark this line as having a reward discount applied + if (line.originalDiscount === undefined) { + line.originalDiscount = line.discount; + } + // Apply the new discount on top of the original + line.discount = line.originalDiscount + validDiscountPercentage; + } + }); + } + + // Return empty array since we're not creating reward lines + return []; + } + + // For non-discount rewards, use the original method + return super._getRewardLineValues(...arguments); + }, + + /** + * Distribute an order-level discount to individual lines + */ + _distributeDiscountToLines(discountAmount, isPercentage = false) { + const lines = this.get_orderlines().filter(line => !line.reward_id); + if (lines.length === 0) return; + + // Calculate total amount for proportional distribution + const totalAmount = lines.reduce((sum, line) => sum + line.get_price_without_tax(), 0); + if (totalAmount <= 0) return; + + if (isPercentage) { + // Apply the same percentage discount to all lines + lines.forEach(line => { + const currentDiscount = line.get_discount(); + // Combine discounts (this is a simplification) + // Store the original discount if not already stored + if (line.originalDiscount === undefined) { + line.originalDiscount = line.discount; + } + const newDiscount = Math.min(100, line.originalDiscount + discountAmount); + line.discount = newDiscount; + }); + } else { + // Distribute fixed amount proportionally based on line subtotal + lines.forEach(line => { + const lineRatio = line.get_price_without_tax() / totalAmount; + const lineDiscountAmount = discountAmount * lineRatio; + // Calculate the additional discount percentage for this line + if (line.get_price_without_tax() > 0) { + const additionalDiscountPercentage = (lineDiscountAmount / line.get_price_without_tax()) * 100; + // Store the original discount if not already stored + if (line.originalDiscount === undefined) { + line.originalDiscount = line.discount; + } + const newDiscountPercentage = Math.min(100, line.originalDiscount + additionalDiscountPercentage); + line.discount = newDiscountPercentage; + } + }); + } + }, + + /** + * Apply a global discount to all order lines + */ + setGlobalDiscount(discountPercentage) { + this.get_orderlines().forEach(line => { + if (!line.is_reward_line) { + // Store the original discount if not already stored + if (line.originalDiscount === undefined) { + line.originalDiscount = line.discount; + } + line.discount = discountPercentage; + } + }); + }, + + /** + * Override to also reset line discounts applied by this module + */ + _resetPrograms() { + // First call the original reset method + super._resetPrograms(...arguments); + + // Reset line discounts that were applied by this module + this.get_orderlines().forEach(line => { + if (!line.is_reward_line && line.originalDiscount !== undefined) { + // Reset to the original discount value + line.discount = line.originalDiscount; + delete line.originalDiscount; + } + }); + }, + + /** + * Clear all line discounts applied by this module + */ + clearLineDiscounts() { + this.get_orderlines().forEach(line => { + if (!line.is_reward_line && line.originalDiscount !== undefined) { + // Reset to the original discount value + line.discount = line.originalDiscount; + delete line.originalDiscount; + } + }); + } +}); + +// Patch Orderline methods to handle line discounts +patch(Orderline.prototype, { + /** + * Override to handle line discount changes + */ + set_discount(discount) { + // Call the original method + super.set_discount(...arguments); + + // Store the original discount if not already stored and this is not a reward line + if (this.originalDiscount === undefined && !this.is_reward_line) { + this.originalDiscount = this.discount; + } + }, + + /** + * Get the discount amount for this line + */ + get_discount_amount() { + return this.get_price_without_tax() * (this.get_discount() / 100); + } +}); diff --git a/views/pos_config_views.xml b/views/pos_config_views.xml new file mode 100644 index 0000000..2d1a31e --- /dev/null +++ b/views/pos_config_views.xml @@ -0,0 +1,29 @@ + + + + + pos.config.form.line.discount + pos.config + + + +
+
+

Line Discount Options

+
+
+
+
+
+
+
+
+
+
+
+
+