From 7747732e197cde1372ba225e467782ee37adfd9e Mon Sep 17 00:00:00 2001 From: Mostafa Kadry Date: Mon, 12 Jan 2026 13:47:15 +0000 Subject: [PATCH 01/14] feat(pricing): implement cheapest item discount functionality - Added new custom fields to Pricing Rule for applying discounts on the cheapest items. - Introduced a new function to patch the pricing rule application to support the new discount logic. --- pos_next/fixtures/custom_field.json | 114 +++++++ pos_next/hooks.py | 21 +- pos_next/install.py | 16 + pos_next/overrides/pricing_rule.py | 13 + pos_next/overrides/sales_invoice.py | 11 + .../pricing_utils/cheapest_item_discount.py | 287 ++++++++++++++++++ 6 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 pos_next/overrides/pricing_rule.py create mode 100644 pos_next/pricing_utils/cheapest_item_discount.py diff --git a/pos_next/fixtures/custom_field.json b/pos_next/fixtures/custom_field.json index c0a8fb93..246e696e 100644 --- a/pos_next/fixtures/custom_field.json +++ b/pos_next/fixtures/custom_field.json @@ -511,5 +511,119 @@ "translatable": 0, "unique": 0, "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": "0", + "depends_on": "eval:doc.price_or_product_discount == 'Price'", + "description": "If enabled, discount will be applied to the cheapest items from entire document", + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Pricing Rule", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "apply_discount_on_cheapest", + "fieldtype": "Check", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "rate_or_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Apply Discount on Cheapest Items", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2025-01-01 00:00:00.000000", + "module": "POS Next", + "name": "Pricing Rule-apply_discount_on_cheapest", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": "0", + "depends_on": "eval:doc.apply_discount_on_cheapest == 1", + "description": "Quantity of cheapest items to apply discount on (M qty)", + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Pricing Rule", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "cheapest_qty", + "fieldtype": "Float", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "apply_discount_on_cheapest", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Cheapest Items Qty", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2025-01-01 00:00:00.000000", + "module": "POS Next", + "name": "Pricing Rule-cheapest_qty", + "no_copy": 0, + "non_negative": 1, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null } ] diff --git a/pos_next/hooks.py b/pos_next/hooks.py index 753ae2db..6b15e141 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -100,7 +100,9 @@ "POS Profile-posa_cash_mode_of_payment", "POS Profile-posa_allow_delete", "POS Profile-posa_block_sale_beyond_available_qty", - "Mode of Payment-is_wallet_payment" + "Mode of Payment-is_wallet_payment", + "Pricing Rule-apply_discount_on_cheapest", + "Pricing Rule-cheapest_qty" ] ] ] @@ -138,6 +140,13 @@ after_install = "pos_next.install.after_install" after_migrate = "pos_next.install.after_migrate" +# Patch pricing rule function on app startup +try: + from pos_next.install import patch_pricing_rule_function + patch_pricing_rule_function() +except Exception: + pass + # Uninstallation # ------------ @@ -219,6 +228,12 @@ }, "POS Profile": { "on_update": "pos_next.realtime_events.emit_pos_profile_updated_event" + }, + "Sales Order": { + "before_validate": "pos_next.pricing_utils.cheapest_item_discount.apply_cheapest_item_discounts" + }, + "Quotation": { + "before_validate": "pos_next.pricing_utils.cheapest_item_discount.apply_cheapest_item_discounts" } } @@ -246,10 +261,6 @@ # Overriding Methods # ------------------------------ # -# override_whitelisted_methods = { -# "frappe.desk.doctype.event.event.get_events": "pos_next.event.get_events" -# } -# # each overriding function accepts a `data` argument; # generated from the base implementation of the doctype dashboard, # along with any modifications made in other Frappe apps diff --git a/pos_next/install.py b/pos_next/install.py index ff74b3b7..49c5a3ec 100644 --- a/pos_next/install.py +++ b/pos_next/install.py @@ -32,6 +32,7 @@ def after_migrate(): # Migrate runs often, so we use quiet mode to reduce noise install_fixtures(quiet=True) setup_default_print_format(quiet=True) + patch_pricing_rule_function() frappe.db.commit() log_message("POS Next: Fixtures updated successfully", level="success") except Exception as e: @@ -253,6 +254,21 @@ def setup_default_print_format(quiet=False): ) +def patch_pricing_rule_function(): + """ + Monkey patch apply_price_discount_rule to support cheapest item discount. + """ + try: + from erpnext.accounts.doctype.pricing_rule import pricing_rule as pricing_rule_module + from pos_next.overrides.pricing_rule import apply_price_discount_rule + + # Patch the function + pricing_rule_module.apply_price_discount_rule = apply_price_discount_rule + log_message("Patched apply_price_discount_rule for cheapest item discount", level="info", quiet=True) + except Exception as e: + # Silently fail if ERPNext not available or already patched + pass + def log_message(message, level="info", indent=0): """ Standardized logging function with consistent formatting diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py new file mode 100644 index 00000000..2ea25a50 --- /dev/null +++ b/pos_next/overrides/pricing_rule.py @@ -0,0 +1,13 @@ +from erpnext.accounts.doctype.pricing_rule.pricing_rule import apply_price_discount_rule as _original_apply_price_discount_rule + +def apply_price_discount_rule(pricing_rule, item_details, args): + """ + Override to handle apply_discount_on_cheapest flag. + + When this flag is enabled, we skip immediate discount application + and mark it for post-processing to apply on cheapest items. + """ + if pricing_rule.get("apply_discount_on_cheapest"): + return + + return _original_apply_price_discount_rule(pricing_rule, item_details, args) \ No newline at end of file diff --git a/pos_next/overrides/sales_invoice.py b/pos_next/overrides/sales_invoice.py index 9ceda345..f5e3a698 100644 --- a/pos_next/overrides/sales_invoice.py +++ b/pos_next/overrides/sales_invoice.py @@ -11,6 +11,7 @@ from frappe.utils import cint, flt from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice from erpnext.accounts.utils import get_account_currency +from pos_next.pricing_utils.cheapest_item_discount import apply_cheapest_item_discounts class CustomSalesInvoice(SalesInvoice): @@ -110,3 +111,13 @@ def get_party_and_party_type_for_pos_gl_entry(self, mode_of_payment, account): party_type, party = "Customer", self.customer return party_type, party + + def validate(self): + """ + Override validate to apply cheapest item discounts after standard pricing rules. + """ + # Call parent validate first (this applies standard pricing rules) + super().validate() + + # Apply cheapest item discounts after all standard pricing rules are processed + apply_cheapest_item_discounts(self) diff --git a/pos_next/pricing_utils/cheapest_item_discount.py b/pos_next/pricing_utils/cheapest_item_discount.py new file mode 100644 index 00000000..602d5835 --- /dev/null +++ b/pos_next/pricing_utils/cheapest_item_discount.py @@ -0,0 +1,287 @@ +# Copyright (c) 2025, BrainWise and contributors +import frappe +from frappe import _ +from frappe.utils import flt +from erpnext.accounts.doctype.pricing_rule.utils import get_applied_pricing_rules +from erpnext.setup.doctype.item_group.item_group import get_child_item_groups + + +def apply_cheapest_item_discounts(doc): + """ + Apply discounts to cheapest items after all standard pricing rules are evaluated. + + This processes pricing rules that have apply_discount_on_cheapest enabled. + + It checks if the condition (N qty from Item Code/Item Group/Brand) is met, + and if so, applies discount to the cheapest M items from the ENTIRE document. + + Args: + doc: Sales Invoice, Sales Order, or Quotation document + """ + if not hasattr(doc, "items") or not doc.items: + return + + # Collect pricing rules that need cheapest item discount processing + cheapest_discount_rules = {} + + # First: collect all pricing rules with apply_discount_on_cheapest + for item in doc.items: + if not item.get("pricing_rules") or item.get("is_free_item"): + continue + + pricing_rules = get_applied_pricing_rules(item.get("pricing_rules")) + for pr_name in pricing_rules: + try: + pr = frappe.get_cached_doc("Pricing Rule", pr_name) + + if pr.get("price_or_product_discount") != "Price": + continue + + if pr.get("apply_discount_on_cheapest") and pr.get("cheapest_qty"): + if _check_pricing_rule_condition_met(doc, pr): + if pr_name not in cheapest_discount_rules: + cheapest_discount_rules[pr_name] = { + "pricing_rule": pr, + "cheapest_qty": flt(pr.get("cheapest_qty", 0)), + "discount_percentage": flt(pr.get("discount_percentage", 0)), + "discount_amount": flt(pr.get("discount_amount", 0)), + "rate_or_discount": pr.get("rate_or_discount"), + } + except Exception: + # Skip if pricing rule doesn't exist + continue + + if not cheapest_discount_rules: + return + + # Second: For each rule, find cheapest items that HAVE the pricing rule and apply discount + # look at items that have this pricing rule, then select cheapest ones + for pr_name, rule_data in cheapest_discount_rules.items(): + pr = rule_data["pricing_rule"] + + # Get items that have this pricing rule applied + items_with_rule = [] + for item in doc.items: + if item.get("is_free_item"): + continue + + # Check if this item has the pricing rule + item_pricing_rules = get_applied_pricing_rules(item.get("pricing_rules") or "") + if pr_name in item_pricing_rules: + items_with_rule.append(item) + + if not items_with_rule: + continue + + # Sort by price_list_rate (cheapest first) + # If price_list_rate not available, use rate + items_with_rule.sort( + key=lambda x: flt(x.get("price_list_rate") or x.get("rate") or 0) + ) + + # Apply discount to cheapest M items (from items that have the pricing rule) + remaining_qty = rule_data["cheapest_qty"] + + for item in items_with_rule: + if remaining_qty <= 0: + break + + # Calculate how much qty to discount for this item + item_qty = flt(item.get("qty") or 0) + if item_qty <= 0: + continue + + discount_qty = min(item_qty, remaining_qty) + remaining_qty -= discount_qty + + # Apply discount to this item + _apply_discount_to_item(item, rule_data, discount_qty, item_qty) + + # Recalculate totals after applying discounts + if hasattr(doc, "calculate_taxes_and_totals"): + doc.calculate_taxes_and_totals() + + +def _check_pricing_rule_condition_met(doc, pricing_rule): + """ + Check if minimum qty/amount condition is met for the pricing rule. + + Supports all apply_on types: Item Code, Item Group, and Brand. + + Args: + doc: Document with items + pricing_rule: Pricing Rule document + + Returns: + bool: True if condition is met + """ + apply_on = pricing_rule.get("apply_on") + + # Only support Item Code, Item Group, and Brand + if apply_on not in ["Item Code", "Item Group", "Brand"]: + return False + + # Get the items/codes that match the pricing rule criteria + matching_items = _get_matching_items_for_pricing_rule(doc, pricing_rule) + + if not matching_items: + return False + + # Calculate total qty/amount from matching items + total_qty = 0 + total_amt = 0 + + for item in doc.items: + if item.get("is_free_item"): + continue + + # Check if this item matches the pricing rule criteria + item_matches = False + + if apply_on == "Item Code": + item_code = item.get("item_code") + if item_code in matching_items: + item_matches = True + + elif apply_on == "Item Group": + item_group = item.get("item_group") + if item_group in matching_items: + item_matches = True + + elif apply_on == "Brand": + brand = item.get("brand") + if brand in matching_items: + item_matches = True + + if item_matches: + # Use stock_qty if available, else qty + qty = flt(item.get("stock_qty") or item.get("qty") or 0) + total_qty += qty + + # Calculate amount + rate = flt(item.get("price_list_rate") or item.get("rate") or 0) + total_amt += qty * rate + + # Check if min_qty or min_amt condition is met + min_qty = flt(pricing_rule.get("min_qty") or 0) + min_amt = flt(pricing_rule.get("min_amt") or 0) + + if min_qty > 0 and total_qty >= min_qty: + return True + if min_amt > 0 and total_amt >= min_amt: + return True + + return False + + +def _get_matching_items_for_pricing_rule(doc, pricing_rule): + """ + Get list of items/codes that match the pricing rule criteria. + + Args: + doc: Document with items + pricing_rule: Pricing Rule document + + Returns: + list: List of matching item codes, item groups, or brands + """ + apply_on = pricing_rule.get("apply_on") + matching_items = [] + + if apply_on == "Item Code": + # Get item codes from pricing rule + for row in pricing_rule.get("items", []): + item_code = row.get("item_code") + if item_code: + matching_items.append(item_code) + + elif apply_on == "Item Group": + # Get item groups from pricing rule (including child groups) + for row in pricing_rule.get("item_groups", []): + item_group = row.get("item_group") + if item_group: + # Include child item groups + matching_items.extend(get_child_item_groups(item_group)) + matching_items.append(item_group) + + elif apply_on == "Brand": + # Get brands from pricing rule + for row in pricing_rule.get("brands", []): + brand = row.get("brand") + if brand: + matching_items.append(brand) + + # Remove duplicates and return + return list(set(matching_items)) + + +def _apply_discount_to_item(item, rule_data, discount_qty, item_qty): + """ + Apply discount to a single item based on rule data. + + Args: + item: Item row + rule_data: Dictionary containing pricing rule and discount info + discount_qty: Quantity of this item to apply discount on + item_qty: Total quantity of this item + """ + pr = rule_data["pricing_rule"] + rate_or_discount = rule_data["rate_or_discount"] + + # Get current item rate + item_rate = flt(item.get("price_list_rate") or item.get("rate") or 0) + if item_rate <= 0: + return + + # Calculate discount based on rate_or_discount type + if rate_or_discount == "Discount Percentage": + # Calculate discount amount for this item proportionally + discount_percentage = rule_data["discount_percentage"] + discount_amt_per_unit = item_rate * (discount_percentage / 100) + discount_amt = discount_amt_per_unit * (discount_qty / item_qty) + + # Add to existing discount + current_discount_amt = flt(item.get("discount_amount") or 0) + item.discount_amount = current_discount_amt + discount_amt + + # Recalculate discount percentage + if item_rate > 0: + total_discount_pct = (item.discount_amount / item_rate) * 100 + item.discount_percentage = flt(total_discount_pct, 2) + + elif rate_or_discount == "Discount Amount": + # Apply discount amount proportionally + discount_amt_per_unit = rule_data["discount_amount"] + discount_amt = discount_amt_per_unit * (discount_qty / item_qty) + + # Add to existing discount + current_discount_amt = flt(item.get("discount_amount") or 0) + item.discount_amount = current_discount_amt + discount_amt + + # Recalculate discount percentage + if item_rate > 0: + item.discount_percentage = flt((item.discount_amount / item_rate) * 100, 2) + + # Recalculate item rate + if item.get("price_list_rate"): + # Apply discount percentage + if item.get("discount_percentage"): + item.rate = flt( + item.price_list_rate * (1.0 - (flt(item.discount_percentage) / 100.0)), + item.precision("rate") if hasattr(item, "precision") else 2 + ) + + # Apply discount amount (takes precedence) + if item.get("discount_amount"): + item.rate = flt( + item.price_list_rate - item.discount_amount, + item.precision("rate") if hasattr(item, "precision") else 2 + ) + + # Recalculate item amount + if hasattr(item, "amount"): + item.amount = flt( + item.rate * item.qty, + item.precision("amount") if hasattr(item, "precision") else 2 + ) + From 2fbb4978a98222241e874c2ba6e39a723ee53b17 Mon Sep 17 00:00:00 2001 From: Mostafa Kadry Date: Tue, 13 Jan 2026 15:33:01 +0000 Subject: [PATCH 02/14] refactor(pricing): update discount application logic --- pos_next/fixtures/custom_field.json | 71 +---- pos_next/hooks.py | 30 +- pos_next/install.py | 16 - pos_next/overrides/pricing_rule.py | 108 ++++++- pos_next/overrides/sales_invoice.py | 13 +- .../pricing_utils/cheapest_item_discount.py | 287 ------------------ 6 files changed, 126 insertions(+), 399 deletions(-) delete mode 100644 pos_next/pricing_utils/cheapest_item_discount.py diff --git a/pos_next/fixtures/custom_field.json b/pos_next/fixtures/custom_field.json index 246e696e..123a7de8 100644 --- a/pos_next/fixtures/custom_field.json +++ b/pos_next/fixtures/custom_field.json @@ -519,16 +519,16 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "default": "0", + "default": "", "depends_on": "eval:doc.price_or_product_discount == 'Price'", - "description": "If enabled, discount will be applied to the cheapest items from entire document", + "description": "Apply discount on Min (cheapest) or Max (most expensive) item from items that have this pricing rule", "docstatus": 0, "doctype": "Custom Field", "dt": "Pricing Rule", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "apply_discount_on_cheapest", - "fieldtype": "Check", + "fieldname": "apply_discount_on_price", + "fieldtype": "Select", "hidden": 0, "hide_border": 0, "hide_days": 0, @@ -542,73 +542,16 @@ "insert_after": "rate_or_discount", "is_system_generated": 0, "is_virtual": 0, - "label": "Apply Discount on Cheapest Items", + "label": "Apply Discount On", "length": 0, "link_filters": null, "mandatory_depends_on": null, "modified": "2025-01-01 00:00:00.000000", "module": "POS Next", - "name": "Pricing Rule-apply_discount_on_cheapest", + "name": "Pricing Rule-apply_discount_on_price", "no_copy": 0, "non_negative": 0, - "options": null, - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - }, - { - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "default": "0", - "depends_on": "eval:doc.apply_discount_on_cheapest == 1", - "description": "Quantity of cheapest items to apply discount on (M qty)", - "docstatus": 0, - "doctype": "Custom Field", - "dt": "Pricing Rule", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "cheapest_qty", - "fieldtype": "Float", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "apply_discount_on_cheapest", - "is_system_generated": 0, - "is_virtual": 0, - "label": "Cheapest Items Qty", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-01-01 00:00:00.000000", - "module": "POS Next", - "name": "Pricing Rule-cheapest_qty", - "no_copy": 0, - "non_negative": 1, - "options": null, + "options": "\nMin\nMax", "permlevel": 0, "placeholder": null, "precision": "", diff --git a/pos_next/hooks.py b/pos_next/hooks.py index 6b15e141..fc70ecbf 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -101,8 +101,7 @@ "POS Profile-posa_allow_delete", "POS Profile-posa_block_sale_beyond_available_qty", "Mode of Payment-is_wallet_payment", - "Pricing Rule-apply_discount_on_cheapest", - "Pricing Rule-cheapest_qty" + "Pricing Rule-apply_discount_on_price" ] ] ] @@ -140,13 +139,6 @@ after_install = "pos_next.install.after_install" after_migrate = "pos_next.install.after_migrate" -# Patch pricing rule function on app startup -try: - from pos_next.install import patch_pricing_rule_function - patch_pricing_rule_function() -except Exception: - pass - # Uninstallation # ------------ @@ -216,7 +208,8 @@ "Sales Invoice": { "validate": [ "pos_next.api.sales_invoice_hooks.validate", - "pos_next.api.wallet.validate_wallet_payment" + "pos_next.api.wallet.validate_wallet_payment", + "pos_next.overrides.pricing_rule.apply_min_max_price_discounts" ], "before_cancel": "pos_next.api.sales_invoice_hooks.before_cancel", "on_submit": [ @@ -230,10 +223,16 @@ "on_update": "pos_next.realtime_events.emit_pos_profile_updated_event" }, "Sales Order": { - "before_validate": "pos_next.pricing_utils.cheapest_item_discount.apply_cheapest_item_discounts" + "validate": "pos_next.overrides.pricing_rule.apply_min_max_price_discounts" }, "Quotation": { - "before_validate": "pos_next.pricing_utils.cheapest_item_discount.apply_cheapest_item_discounts" + "validate": "pos_next.overrides.pricing_rule.apply_min_max_price_discounts" + }, + "Delivery Note": { + "validate": "pos_next.overrides.pricing_rule.apply_min_max_price_discounts" + }, + "POS Invoice": { + "validate": "pos_next.overrides.pricing_rule.apply_min_max_price_discounts" } } @@ -252,7 +251,12 @@ "pos_next.tasks.branding_monitor.reset_tampering_counter", ], } - +try: + from erpnext.accounts.doctype.pricing_rule import pricing_rule as pricing_rule_module + from pos_next.overrides.pricing_rule import apply_price_discount_rule + pricing_rule_module.apply_price_discount_rule = apply_price_discount_rule +except Exception: + pass # Testing # ------- diff --git a/pos_next/install.py b/pos_next/install.py index 49c5a3ec..ff74b3b7 100644 --- a/pos_next/install.py +++ b/pos_next/install.py @@ -32,7 +32,6 @@ def after_migrate(): # Migrate runs often, so we use quiet mode to reduce noise install_fixtures(quiet=True) setup_default_print_format(quiet=True) - patch_pricing_rule_function() frappe.db.commit() log_message("POS Next: Fixtures updated successfully", level="success") except Exception as e: @@ -254,21 +253,6 @@ def setup_default_print_format(quiet=False): ) -def patch_pricing_rule_function(): - """ - Monkey patch apply_price_discount_rule to support cheapest item discount. - """ - try: - from erpnext.accounts.doctype.pricing_rule import pricing_rule as pricing_rule_module - from pos_next.overrides.pricing_rule import apply_price_discount_rule - - # Patch the function - pricing_rule_module.apply_price_discount_rule = apply_price_discount_rule - log_message("Patched apply_price_discount_rule for cheapest item discount", level="info", quiet=True) - except Exception as e: - # Silently fail if ERPNext not available or already patched - pass - def log_message(message, level="info", indent=0): """ Standardized logging function with consistent formatting diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index 2ea25a50..fd542ea5 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -1,13 +1,107 @@ -from erpnext.accounts.doctype.pricing_rule.pricing_rule import apply_price_discount_rule as _original_apply_price_discount_rule +import frappe +from frappe.utils import flt +from erpnext.accounts.doctype.pricing_rule.utils import get_applied_pricing_rules +from erpnext.accounts.doctype.pricing_rule.pricing_rule import ( + apply_price_discount_rule as _original_apply_price_discount_rule +) + def apply_price_discount_rule(pricing_rule, item_details, args): """ - Override to handle apply_discount_on_cheapest flag. + Override to skip applying discount if apply_discount_on_price is Min/Max. + The discount will be applied by apply_min_max_price_discounts instead. + """ + apply_discount_on_price = pricing_rule.get("apply_discount_on_price") or "" + + # Skip applying discount if Min/Max - will be handled by apply_min_max_price_discounts + if apply_discount_on_price in ["Min", "Max"]: + return - When this flag is enabled, we skip immediate discount application - and mark it for post-processing to apply on cheapest items. + # Otherwise, use original function + return _original_apply_price_discount_rule(pricing_rule, item_details, args) + + +def apply_min_max_price_discounts(doc, method=None): + """ + Apply discount only to the min/max priced item based on applied Pricing Rules. + Reset discount on other items for the same pricing rule. + """ + try: + for pr_name in _get_min_max_pricing_rules(doc): + pr = frappe.get_cached_doc("Pricing Rule", pr_name) + + # Enforce price list + if pr.for_price_list and pr.for_price_list != doc.selling_price_list: + continue + + eligible_items = [ + item for item in doc.items + if not item.is_free_item + and flt(item.qty) > 0 + and item.pricing_rules + and pr_name in get_applied_pricing_rules(item.pricing_rules) + ] + + if not eligible_items: + continue + + reverse = pr.apply_discount_on_price == "Max" + eligible_items.sort( + key=lambda x: flt(x.price_list_rate or x.rate or 0), + reverse=reverse + ) + + target_item = eligible_items[0] + + # Reset discount for non-target items FIRST + for item in eligible_items: + if item != target_item: + # Remove this pricing rule's discount contribution + item.discount_percentage = 0.0 + item.discount_amount = 0.0 + + # Then apply discount to target item + _apply_discount(pr, target_item) + + # Recalculate totals once after processing all pricing rules + if hasattr(doc, "calculate_taxes_and_totals"): + doc.calculate_taxes_and_totals() + + except Exception as e: + frappe.log_error( + frappe.get_traceback(), + "Min/Max Pricing Rule Failed" + ) + +def _get_min_max_pricing_rules(doc): + """ + Extract unique Pricing Rules with apply_discount_on_price. """ - if pricing_rule.get("apply_discount_on_cheapest"): + rules = set() + + for item in doc.items: + for pr_name in get_applied_pricing_rules(item.pricing_rules or ""): + pr = frappe.get_cached_doc("Pricing Rule", pr_name) + if pr.price_or_product_discount == "Price" and pr.apply_discount_on_price in ("Min", "Max"): + rules.add(pr_name) + + return rules + + +def _apply_discount(pricing_rule, item): + """ + Apply discount safely to a single item. + """ + + base_rate = flt(item.rate or item.price_list_rate) + if not base_rate: return - - return _original_apply_price_discount_rule(pricing_rule, item_details, args) \ No newline at end of file + + if pricing_rule.rate_or_discount == "Discount Percentage": + item.discount_percentage = flt(pricing_rule.discount_percentage) + elif pricing_rule.rate_or_discount == "Discount Amount": + item.discount_amount = flt(pricing_rule.discount_amount) + item.discount_percentage = flt( + (item.discount_amount / base_rate) * 100, + 2 + ) \ No newline at end of file diff --git a/pos_next/overrides/sales_invoice.py b/pos_next/overrides/sales_invoice.py index f5e3a698..ef8edaee 100644 --- a/pos_next/overrides/sales_invoice.py +++ b/pos_next/overrides/sales_invoice.py @@ -11,7 +11,6 @@ from frappe.utils import cint, flt from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice from erpnext.accounts.utils import get_account_currency -from pos_next.pricing_utils.cheapest_item_discount import apply_cheapest_item_discounts class CustomSalesInvoice(SalesInvoice): @@ -110,14 +109,4 @@ def get_party_and_party_type_for_pos_gl_entry(self, mode_of_payment, account): if is_wallet_mode_of_payment: party_type, party = "Customer", self.customer - return party_type, party - - def validate(self): - """ - Override validate to apply cheapest item discounts after standard pricing rules. - """ - # Call parent validate first (this applies standard pricing rules) - super().validate() - - # Apply cheapest item discounts after all standard pricing rules are processed - apply_cheapest_item_discounts(self) + return party_type, party \ No newline at end of file diff --git a/pos_next/pricing_utils/cheapest_item_discount.py b/pos_next/pricing_utils/cheapest_item_discount.py deleted file mode 100644 index 602d5835..00000000 --- a/pos_next/pricing_utils/cheapest_item_discount.py +++ /dev/null @@ -1,287 +0,0 @@ -# Copyright (c) 2025, BrainWise and contributors -import frappe -from frappe import _ -from frappe.utils import flt -from erpnext.accounts.doctype.pricing_rule.utils import get_applied_pricing_rules -from erpnext.setup.doctype.item_group.item_group import get_child_item_groups - - -def apply_cheapest_item_discounts(doc): - """ - Apply discounts to cheapest items after all standard pricing rules are evaluated. - - This processes pricing rules that have apply_discount_on_cheapest enabled. - - It checks if the condition (N qty from Item Code/Item Group/Brand) is met, - and if so, applies discount to the cheapest M items from the ENTIRE document. - - Args: - doc: Sales Invoice, Sales Order, or Quotation document - """ - if not hasattr(doc, "items") or not doc.items: - return - - # Collect pricing rules that need cheapest item discount processing - cheapest_discount_rules = {} - - # First: collect all pricing rules with apply_discount_on_cheapest - for item in doc.items: - if not item.get("pricing_rules") or item.get("is_free_item"): - continue - - pricing_rules = get_applied_pricing_rules(item.get("pricing_rules")) - for pr_name in pricing_rules: - try: - pr = frappe.get_cached_doc("Pricing Rule", pr_name) - - if pr.get("price_or_product_discount") != "Price": - continue - - if pr.get("apply_discount_on_cheapest") and pr.get("cheapest_qty"): - if _check_pricing_rule_condition_met(doc, pr): - if pr_name not in cheapest_discount_rules: - cheapest_discount_rules[pr_name] = { - "pricing_rule": pr, - "cheapest_qty": flt(pr.get("cheapest_qty", 0)), - "discount_percentage": flt(pr.get("discount_percentage", 0)), - "discount_amount": flt(pr.get("discount_amount", 0)), - "rate_or_discount": pr.get("rate_or_discount"), - } - except Exception: - # Skip if pricing rule doesn't exist - continue - - if not cheapest_discount_rules: - return - - # Second: For each rule, find cheapest items that HAVE the pricing rule and apply discount - # look at items that have this pricing rule, then select cheapest ones - for pr_name, rule_data in cheapest_discount_rules.items(): - pr = rule_data["pricing_rule"] - - # Get items that have this pricing rule applied - items_with_rule = [] - for item in doc.items: - if item.get("is_free_item"): - continue - - # Check if this item has the pricing rule - item_pricing_rules = get_applied_pricing_rules(item.get("pricing_rules") or "") - if pr_name in item_pricing_rules: - items_with_rule.append(item) - - if not items_with_rule: - continue - - # Sort by price_list_rate (cheapest first) - # If price_list_rate not available, use rate - items_with_rule.sort( - key=lambda x: flt(x.get("price_list_rate") or x.get("rate") or 0) - ) - - # Apply discount to cheapest M items (from items that have the pricing rule) - remaining_qty = rule_data["cheapest_qty"] - - for item in items_with_rule: - if remaining_qty <= 0: - break - - # Calculate how much qty to discount for this item - item_qty = flt(item.get("qty") or 0) - if item_qty <= 0: - continue - - discount_qty = min(item_qty, remaining_qty) - remaining_qty -= discount_qty - - # Apply discount to this item - _apply_discount_to_item(item, rule_data, discount_qty, item_qty) - - # Recalculate totals after applying discounts - if hasattr(doc, "calculate_taxes_and_totals"): - doc.calculate_taxes_and_totals() - - -def _check_pricing_rule_condition_met(doc, pricing_rule): - """ - Check if minimum qty/amount condition is met for the pricing rule. - - Supports all apply_on types: Item Code, Item Group, and Brand. - - Args: - doc: Document with items - pricing_rule: Pricing Rule document - - Returns: - bool: True if condition is met - """ - apply_on = pricing_rule.get("apply_on") - - # Only support Item Code, Item Group, and Brand - if apply_on not in ["Item Code", "Item Group", "Brand"]: - return False - - # Get the items/codes that match the pricing rule criteria - matching_items = _get_matching_items_for_pricing_rule(doc, pricing_rule) - - if not matching_items: - return False - - # Calculate total qty/amount from matching items - total_qty = 0 - total_amt = 0 - - for item in doc.items: - if item.get("is_free_item"): - continue - - # Check if this item matches the pricing rule criteria - item_matches = False - - if apply_on == "Item Code": - item_code = item.get("item_code") - if item_code in matching_items: - item_matches = True - - elif apply_on == "Item Group": - item_group = item.get("item_group") - if item_group in matching_items: - item_matches = True - - elif apply_on == "Brand": - brand = item.get("brand") - if brand in matching_items: - item_matches = True - - if item_matches: - # Use stock_qty if available, else qty - qty = flt(item.get("stock_qty") or item.get("qty") or 0) - total_qty += qty - - # Calculate amount - rate = flt(item.get("price_list_rate") or item.get("rate") or 0) - total_amt += qty * rate - - # Check if min_qty or min_amt condition is met - min_qty = flt(pricing_rule.get("min_qty") or 0) - min_amt = flt(pricing_rule.get("min_amt") or 0) - - if min_qty > 0 and total_qty >= min_qty: - return True - if min_amt > 0 and total_amt >= min_amt: - return True - - return False - - -def _get_matching_items_for_pricing_rule(doc, pricing_rule): - """ - Get list of items/codes that match the pricing rule criteria. - - Args: - doc: Document with items - pricing_rule: Pricing Rule document - - Returns: - list: List of matching item codes, item groups, or brands - """ - apply_on = pricing_rule.get("apply_on") - matching_items = [] - - if apply_on == "Item Code": - # Get item codes from pricing rule - for row in pricing_rule.get("items", []): - item_code = row.get("item_code") - if item_code: - matching_items.append(item_code) - - elif apply_on == "Item Group": - # Get item groups from pricing rule (including child groups) - for row in pricing_rule.get("item_groups", []): - item_group = row.get("item_group") - if item_group: - # Include child item groups - matching_items.extend(get_child_item_groups(item_group)) - matching_items.append(item_group) - - elif apply_on == "Brand": - # Get brands from pricing rule - for row in pricing_rule.get("brands", []): - brand = row.get("brand") - if brand: - matching_items.append(brand) - - # Remove duplicates and return - return list(set(matching_items)) - - -def _apply_discount_to_item(item, rule_data, discount_qty, item_qty): - """ - Apply discount to a single item based on rule data. - - Args: - item: Item row - rule_data: Dictionary containing pricing rule and discount info - discount_qty: Quantity of this item to apply discount on - item_qty: Total quantity of this item - """ - pr = rule_data["pricing_rule"] - rate_or_discount = rule_data["rate_or_discount"] - - # Get current item rate - item_rate = flt(item.get("price_list_rate") or item.get("rate") or 0) - if item_rate <= 0: - return - - # Calculate discount based on rate_or_discount type - if rate_or_discount == "Discount Percentage": - # Calculate discount amount for this item proportionally - discount_percentage = rule_data["discount_percentage"] - discount_amt_per_unit = item_rate * (discount_percentage / 100) - discount_amt = discount_amt_per_unit * (discount_qty / item_qty) - - # Add to existing discount - current_discount_amt = flt(item.get("discount_amount") or 0) - item.discount_amount = current_discount_amt + discount_amt - - # Recalculate discount percentage - if item_rate > 0: - total_discount_pct = (item.discount_amount / item_rate) * 100 - item.discount_percentage = flt(total_discount_pct, 2) - - elif rate_or_discount == "Discount Amount": - # Apply discount amount proportionally - discount_amt_per_unit = rule_data["discount_amount"] - discount_amt = discount_amt_per_unit * (discount_qty / item_qty) - - # Add to existing discount - current_discount_amt = flt(item.get("discount_amount") or 0) - item.discount_amount = current_discount_amt + discount_amt - - # Recalculate discount percentage - if item_rate > 0: - item.discount_percentage = flt((item.discount_amount / item_rate) * 100, 2) - - # Recalculate item rate - if item.get("price_list_rate"): - # Apply discount percentage - if item.get("discount_percentage"): - item.rate = flt( - item.price_list_rate * (1.0 - (flt(item.discount_percentage) / 100.0)), - item.precision("rate") if hasattr(item, "precision") else 2 - ) - - # Apply discount amount (takes precedence) - if item.get("discount_amount"): - item.rate = flt( - item.price_list_rate - item.discount_amount, - item.precision("rate") if hasattr(item, "precision") else 2 - ) - - # Recalculate item amount - if hasattr(item, "amount"): - item.amount = flt( - item.rate * item.qty, - item.precision("amount") if hasattr(item, "precision") else 2 - ) - From 1321bf1e341f95fd036d7d7e11b95cccc8f11964 Mon Sep 17 00:00:00 2001 From: Mostafa Kadry Date: Tue, 13 Jan 2026 15:54:48 +0000 Subject: [PATCH 03/14] - Updated the discount application logic to ensure rates are calculated correctly without compounding discounts. --- pos_next/hooks.py | 7 +----- pos_next/overrides/pricing_rule.py | 37 ++++++++++++++++-------------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/pos_next/hooks.py b/pos_next/hooks.py index fc70ecbf..a402c3be 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -251,12 +251,7 @@ "pos_next.tasks.branding_monitor.reset_tampering_counter", ], } -try: - from erpnext.accounts.doctype.pricing_rule import pricing_rule as pricing_rule_module - from pos_next.overrides.pricing_rule import apply_price_discount_rule - pricing_rule_module.apply_price_discount_rule = apply_price_discount_rule -except Exception: - pass + # Testing # ------- diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index fd542ea5..03b06056 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -56,9 +56,10 @@ def apply_min_max_price_discounts(doc, method=None): # Reset discount for non-target items FIRST for item in eligible_items: if item != target_item: - # Remove this pricing rule's discount contribution item.discount_percentage = 0.0 item.discount_amount = 0.0 + item.rate = flt(item.price_list_rate) + item.amount = flt(item.rate) * flt(item.qty) # Then apply discount to target item _apply_discount(pr, target_item) @@ -89,19 +90,21 @@ def _get_min_max_pricing_rules(doc): def _apply_discount(pricing_rule, item): - """ - Apply discount safely to a single item. - """ - - base_rate = flt(item.rate or item.price_list_rate) - if not base_rate: - return - - if pricing_rule.rate_or_discount == "Discount Percentage": - item.discount_percentage = flt(pricing_rule.discount_percentage) - elif pricing_rule.rate_or_discount == "Discount Amount": - item.discount_amount = flt(pricing_rule.discount_amount) - item.discount_percentage = flt( - (item.discount_amount / base_rate) * 100, - 2 - ) \ No newline at end of file + """ + Apply discount safely and force the rate to update. + """ + # Use price_list_rate as the source of truth to avoid compounding discounts + base_rate = flt(item.price_list_rate) or flt(item.rate) + if not base_rate: + return + + if pricing_rule.rate_or_discount == "Discount Percentage": + item.discount_percentage = flt(pricing_rule.discount_percentage) + item.discount_amount = 0.0 + elif pricing_rule.rate_or_discount == "Discount Amount": + item.discount_amount = flt(pricing_rule.discount_amount) + item.discount_percentage = 0.0 + + + item.rate = base_rate * (1.0 - (flt(item.discount_percentage) / 100.0)) - flt(item.discount_amount) + item.amount = flt(item.rate) * flt(item.qty) \ No newline at end of file From d0e4df8562f1f72615d85ce2732ac8e849a1e5d6 Mon Sep 17 00:00:00 2001 From: Mostafa Kadry Date: Tue, 13 Jan 2026 15:57:05 +0000 Subject: [PATCH 04/14] remove unwanted changes --- pos_next/hooks.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pos_next/hooks.py b/pos_next/hooks.py index a402c3be..8ac125ec 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -260,6 +260,10 @@ # Overriding Methods # ------------------------------ # +# override_whitelisted_methods = { +# "frappe.desk.doctype.event.event.get_events": "pos_next.event.get_events" +# } +# # each overriding function accepts a `data` argument; # generated from the base implementation of the doctype dashboard, # along with any modifications made in other Frappe apps From ac7eed1f4adc9302c530277419c4600284da14d8 Mon Sep 17 00:00:00 2001 From: Mostafa Kadry Date: Tue, 13 Jan 2026 16:01:06 +0000 Subject: [PATCH 05/14] remove unwanted changes --- pos_next/overrides/sales_invoice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pos_next/overrides/sales_invoice.py b/pos_next/overrides/sales_invoice.py index ef8edaee..9ceda345 100644 --- a/pos_next/overrides/sales_invoice.py +++ b/pos_next/overrides/sales_invoice.py @@ -109,4 +109,4 @@ def get_party_and_party_type_for_pos_gl_entry(self, mode_of_payment, account): if is_wallet_mode_of_payment: party_type, party = "Customer", self.customer - return party_type, party \ No newline at end of file + return party_type, party From c675c9b3e8d62a9a380a6a4072c6f31133a241cc Mon Sep 17 00:00:00 2001 From: Mostafa Kadry Date: Wed, 14 Jan 2026 15:26:05 +0000 Subject: [PATCH 06/14] fix(pricing): enhance pricing rule logic and error handling --- pos_next/hooks.py | 9 +++++++-- pos_next/overrides/pricing_rule.py | 8 ++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/pos_next/hooks.py b/pos_next/hooks.py index 8ac125ec..74f3751b 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -1,5 +1,5 @@ from pos_next.utils import get_build_version - +import frappe app_name = "pos_next" app_title = "POS Next" app_publisher = "BrainWise" @@ -251,7 +251,12 @@ "pos_next.tasks.branding_monitor.reset_tampering_counter", ], } - +try: + from erpnext.accounts.doctype.pricing_rule import pricing_rule as pricing_rule_module + from pos_next.overrides.pricing_rule import apply_price_discount_rule + pricing_rule_module.apply_price_discount_rule = apply_price_discount_rule +except Exception as e: + frappe.log_error(frappe.get_traceback(), "Error in pricing rule module") # Testing # ------- diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index 03b06056..0cc31304 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -27,7 +27,11 @@ def apply_min_max_price_discounts(doc, method=None): Reset discount on other items for the same pricing rule. """ try: - for pr_name in _get_min_max_pricing_rules(doc): + min_max_rules = _get_min_max_pricing_rules(doc) + if not min_max_rules: + return + + for pr_name in min_max_rules: pr = frappe.get_cached_doc("Pricing Rule", pr_name) # Enforce price list @@ -45,7 +49,7 @@ def apply_min_max_price_discounts(doc, method=None): if not eligible_items: continue - reverse = pr.apply_discount_on_price == "Max" + reverse = pr.apply_discount_on_price == "Min" eligible_items.sort( key=lambda x: flt(x.price_list_rate or x.rate or 0), reverse=reverse From 44658bd5d30c40060762d8055d23d1d5fcf453bb Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Thu, 22 Jan 2026 16:57:35 +0200 Subject: [PATCH 07/14] perf(pricing-rule): optimize min/max price discount application --- pos_next/overrides/pricing_rule.py | 93 ++++++++++++++---------------- 1 file changed, 44 insertions(+), 49 deletions(-) diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index 0cc31304..e61479b6 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -1,5 +1,6 @@ import frappe from frappe.utils import flt +from collections import defaultdict from erpnext.accounts.doctype.pricing_rule.utils import get_applied_pricing_rules from erpnext.accounts.doctype.pricing_rule.pricing_rule import ( apply_price_discount_rule as _original_apply_price_discount_rule @@ -27,70 +28,39 @@ def apply_min_max_price_discounts(doc, method=None): Reset discount on other items for the same pricing rule. """ try: - min_max_rules = _get_min_max_pricing_rules(doc) - if not min_max_rules: - return - - for pr_name in min_max_rules: - pr = frappe.get_cached_doc("Pricing Rule", pr_name) - - # Enforce price list + rule_items, pricing_rules = _collect_min_max_rule_items(doc) + + for pr_name, items in rule_items.items(): + pr = pricing_rules[pr_name] + if pr.for_price_list and pr.for_price_list != doc.selling_price_list: continue - eligible_items = [ - item for item in doc.items - if not item.is_free_item - and flt(item.qty) > 0 - and item.pricing_rules - and pr_name in get_applied_pricing_rules(item.pricing_rules) - ] - - if not eligible_items: + if not items: continue - reverse = pr.apply_discount_on_price == "Min" - eligible_items.sort( - key=lambda x: flt(x.price_list_rate or x.rate or 0), - reverse=reverse + key = lambda i: flt(i.price_list_rate or i.rate or 0) + target_item = ( + max(items, key=key) + if pr.apply_discount_on_price == "Max" + else min(items, key=key) ) - target_item = eligible_items[0] - - # Reset discount for non-target items FIRST - for item in eligible_items: + for item in items: if item != target_item: item.discount_percentage = 0.0 item.discount_amount = 0.0 item.rate = flt(item.price_list_rate) - item.amount = flt(item.rate) * flt(item.qty) - - # Then apply discount to target item + item.amount = item.rate * item.qty + _apply_discount(pr, target_item) - - # Recalculate totals once after processing all pricing rules + if hasattr(doc, "calculate_taxes_and_totals"): doc.calculate_taxes_and_totals() - - except Exception as e: - frappe.log_error( - frappe.get_traceback(), - "Min/Max Pricing Rule Failed" - ) - -def _get_min_max_pricing_rules(doc): - """ - Extract unique Pricing Rules with apply_discount_on_price. - """ - rules = set() - for item in doc.items: - for pr_name in get_applied_pricing_rules(item.pricing_rules or ""): - pr = frappe.get_cached_doc("Pricing Rule", pr_name) - if pr.price_or_product_discount == "Price" and pr.apply_discount_on_price in ("Min", "Max"): - rules.add(pr_name) + except Exception: + frappe.log_error(frappe.get_traceback(), "Min/Max Pricing Rule Failed") - return rules def _apply_discount(pricing_rule, item): @@ -111,4 +81,29 @@ def _apply_discount(pricing_rule, item): item.rate = base_rate * (1.0 - (flt(item.discount_percentage) / 100.0)) - flt(item.discount_amount) - item.amount = flt(item.rate) * flt(item.qty) \ No newline at end of file + item.amount = flt(item.rate) * flt(item.qty) + + +def _collect_min_max_rule_items(doc): + rule_items = defaultdict(list) + pricing_rules_cache = {} + + for item in doc.items: + if item.is_free_item or flt(item.qty) <= 0 or not item.pricing_rules: + continue + + for pr_name in get_applied_pricing_rules(item.pricing_rules): + if pr_name not in pricing_rules_cache: + pr = frappe.get_cached_doc("Pricing Rule", pr_name) + if ( + pr.price_or_product_discount == "Price" + and pr.apply_discount_on_price in ("Min", "Max") + ): + pricing_rules_cache[pr_name] = pr + else: + pricing_rules_cache[pr_name] = None + + if pricing_rules_cache[pr_name]: + rule_items[pr_name].append(item) + + return rule_items, pricing_rules_cache \ No newline at end of file From fa37e634b5f2cfb8b6a833ebfb9c4e64babed5a5 Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Sun, 25 Jan 2026 16:50:48 +0200 Subject: [PATCH 08/14] feat(pricing): add support for Min/Max discount quantity limit - Introduced a new custom field for Pricing Rule to specify a quantity limit for applying discounts on items with minimum or maximum prices. - Updated the discount application logic to handle Min/Max pricing rules, ensuring discounts are applied correctly based on the specified quantity limits. --- pos_next/fixtures/custom_field.json | 57 +++++++++ pos_next/hooks.py | 3 +- pos_next/overrides/pricing_rule.py | 188 +++++++++++++++++++++++----- 3 files changed, 215 insertions(+), 33 deletions(-) diff --git a/pos_next/fixtures/custom_field.json b/pos_next/fixtures/custom_field.json index 123a7de8..97641bc3 100644 --- a/pos_next/fixtures/custom_field.json +++ b/pos_next/fixtures/custom_field.json @@ -568,5 +568,62 @@ "translatable": 0, "unique": 0, "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": "0.0", + "depends_on": "eval:doc.apply_discount_on_price == 'Min' || doc.apply_discount_on_price == 'Max'", + "description": "Quantity limit for applying discount on Min/Max priced item. Leave 0 for no limit.", + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Pricing Rule", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "min_or_max_discount_qty_limit", + "fieldtype": "Float", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "apply_discount_on_price", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Min/Max Discount Qty Limit", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2025-01-01 00:00:00.000000", + "module": "POS Next", + "name": "Pricing Rule-min_or_max_discount_qty_limit", + "no_copy": 0, + "non_negative": 1, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null } ] diff --git a/pos_next/hooks.py b/pos_next/hooks.py index 74f3751b..93fa1506 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -101,7 +101,8 @@ "POS Profile-posa_allow_delete", "POS Profile-posa_block_sale_beyond_available_qty", "Mode of Payment-is_wallet_payment", - "Pricing Rule-apply_discount_on_price" + "Pricing Rule-apply_discount_on_price", + "Pricing Rule-min_or_max_discount_qty_limit" ] ] ] diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index e61479b6..46407b96 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -1,3 +1,12 @@ +""" +Override module for ERPNext Pricing Rules to support Min/Max price-based discounts. + +This module extends the standard pricing rule functionality to handle discounts +that should be applied only to items with minimum or maximum prices within a +transaction. The Min/Max discount logic requires special handling because it +needs to evaluate all items together to determine which items qualify for the +discount based on their relative prices. +""" import frappe from frappe.utils import flt from collections import defaultdict @@ -9,101 +18,216 @@ def apply_price_discount_rule(pricing_rule, item_details, args): """ - Override to skip applying discount if apply_discount_on_price is Min/Max. - The discount will be applied by apply_min_max_price_discounts instead. + Override function to defer Min/Max discount application to a later stage. + + This function intercepts the standard pricing rule application process. + For Min/Max pricing rules, it skips the immediate discount application + because these rules require evaluating all items together to determine + which items qualify based on their relative prices. + + Args: + pricing_rule (dict): The pricing rule configuration dictionary + item_details (dict): Details about the item being processed + args (dict): Additional arguments for the pricing rule application + + Returns: + None: If the rule is Min/Max type (deferred to apply_min_max_price_discounts) + Any: Result from the original function for non-Min/Max rules """ apply_discount_on_price = pricing_rule.get("apply_discount_on_price") or "" - # Skip applying discount if Min/Max - will be handled by apply_min_max_price_discounts + # Defer Min/Max discount application - these require evaluating all items + # together to determine which items qualify based on price ranking if apply_discount_on_price in ["Min", "Max"]: return - # Otherwise, use original function + # For standard pricing rules, use the original implementation return _original_apply_price_discount_rule(pricing_rule, item_details, args) def apply_min_max_price_discounts(doc, method=None): """ - Apply discount only to the min/max priced item based on applied Pricing Rules. - Reset discount on other items for the same pricing rule. + Apply Min/Max pricing rule discounts to document items. + + This function processes all items that have Min/Max pricing rules applied. + It sorts items by price (ascending for Min, descending for Max) and applies + discounts only to the items that qualify based on the quantity limit specified + in the pricing rule. + + The discount is applied in priority order: + - For "Min" rules: Items with lowest prices get discounted first + - For "Max" rules: Items with highest prices get discounted first + + Args: + doc: The document (e.g., Sales Invoice, Quotation) containing items + method (str, optional): Hook method name if called via Frappe hooks """ try: + # Collect all items that have Min/Max pricing rules applied rule_items, pricing_rules = _collect_min_max_rule_items(doc) + # Process each pricing rule separately for pr_name, items in rule_items.items(): pr = pricing_rules[pr_name] + # Skip if pricing rule is restricted to a different price list if pr.for_price_list and pr.for_price_list != doc.selling_price_list: continue + # Skip if no items match this rule if not items: continue - key = lambda i: flt(i.price_list_rate or i.rate or 0) - target_item = ( - max(items, key=key) - if pr.apply_discount_on_price == "Max" - else min(items, key=key) + # Determine sort direction: Max rules need descending order (highest first) + # Min rules need ascending order (lowest first) + reverse = pr.apply_discount_on_price == "Max" + items.sort( + key=lambda i: flt(i.price_list_rate or i.rate or 0), + reverse=reverse ) + # Track remaining quantity that can receive discount + # This limit ensures only a specified quantity gets discounted + remaining_qty = flt(pr.min_or_max_discount_qty_limit or 0) + + # Apply discount to items in priority order (sorted by price) for item in items: - if item != target_item: + base_rate = flt(item.price_list_rate) or flt(item.rate) + if not base_rate: + continue + + # If discount quantity limit exhausted, reset item to base price + if remaining_qty <= 0: item.discount_percentage = 0.0 item.discount_amount = 0.0 - item.rate = flt(item.price_list_rate) - item.amount = item.rate * item.qty - - _apply_discount(pr, target_item) - + # item.rate = base_rate + # item.amount = item.rate * item.qty + continue + + # Calculate how much of this item's quantity qualifies for discount + # This ensures we don't exceed the total discount quantity limit + discount_qty = ( + item.qty if remaining_qty <= 0 + else min(item.qty, remaining_qty) + ) + + # Apply discount to the eligible quantity + _apply_discount(pr, item, discount_qty) + remaining_qty -= discount_qty + + # Recalculate totals after applying all discounts if hasattr(doc, "calculate_taxes_and_totals"): doc.calculate_taxes_and_totals() except Exception: + # Log errors but don't break the document processing flow frappe.log_error(frappe.get_traceback(), "Min/Max Pricing Rule Failed") + frappe.throw( + _("Failed to apply pricing rule discounts. Please review pricing rules or contact support."), + title="Min/Max Pricing Rule Failed" + ) - -def _apply_discount(pricing_rule, item): +def _apply_discount(pr, item, eligible_qty): """ - Apply discount safely and force the rate to update. + Apply discount to an item based on the pricing rule configuration. + + This helper function calculates and applies the discount amount based on + the pricing rule's discount type (Rate, Discount Percentage, or Discount Amount). + It ensures that discounts never result in negative item rates. + + Args: + pr: The Pricing Rule document + item: The item row to apply discount to + eligible_qty (float): The quantity of this item that qualifies for discount """ - # Use price_list_rate as the source of truth to avoid compounding discounts base_rate = flt(item.price_list_rate) or flt(item.rate) - if not base_rate: + if not base_rate or eligible_qty <= 0: return - if pricing_rule.rate_or_discount == "Discount Percentage": - item.discount_percentage = flt(pricing_rule.discount_percentage) - item.discount_amount = 0.0 - elif pricing_rule.rate_or_discount == "Discount Amount": - item.discount_amount = flt(pricing_rule.discount_amount) - item.discount_percentage = 0.0 - + qty = flt(item.qty) + # Calculate maximum possible discount to prevent negative rates + # This ensures the discount never exceeds the item's base value + base_discount_cap = base_rate * eligible_qty + total_discount = 0.0 + + # Calculate discount based on pricing rule type + if pr.rate_or_discount == "Rate" and pr.rate: + # Fixed rate: discount is the difference between base rate and rule rate + total_discount = (base_rate - flt(pr.rate)) * eligible_qty + + elif pr.rate_or_discount == "Discount Percentage" and pr.discount_percentage: + # Percentage discount: calculate per unit, then multiply by eligible quantity + per_unit_discount = base_rate * flt(pr.discount_percentage) / 100 + total_discount = per_unit_discount * eligible_qty + + elif pr.rate_or_discount == "Discount Amount" and pr.discount_amount: + # Fixed amount discount: apply per unit of eligible quantity + total_discount = flt(pr.discount_amount) * eligible_qty + + # Enforce discount cap to prevent negative rates + total_discount = min(total_discount, base_discount_cap) + + # Calculate effective rate after discount + # Note: discount is applied to eligible_qty, but rate is calculated per total qty + effective_rate = (base_rate * qty - total_discount) / qty - item.rate = base_rate * (1.0 - (flt(item.discount_percentage) / 100.0)) - flt(item.discount_amount) - item.amount = flt(item.rate) * flt(item.qty) + # Update item fields with calculated values + item.rate = max(effective_rate, 0.0) # Ensure rate is never negative + item.discount_amount = total_discount + # Calculate discount percentage based on total item value + item.discount_percentage = ( + total_discount / (base_rate * qty) + ) * 100 if base_rate * qty else 0.0 + + # Recalculate item amount with new rate + item.amount = item.rate * qty def _collect_min_max_rule_items(doc): + """ + Collect all items that have Min/Max pricing rules applied. + + This function iterates through all items in the document and identifies + which items are subject to Min/Max pricing rules. It uses caching to + avoid repeatedly fetching the same pricing rule documents. + + Args: + doc: The document containing items to process + + Returns: + tuple: A tuple containing: + - rule_items (dict): Dictionary mapping pricing rule names to lists of items + - pricing_rules_cache (dict): Dictionary of cached Pricing Rule documents + """ + # Group items by their pricing rules rule_items = defaultdict(list) + # Cache pricing rule documents to avoid redundant database queries pricing_rules_cache = {} for item in doc.items: + # Skip free items, items with zero/negative quantity, or items without pricing rules if item.is_free_item or flt(item.qty) <= 0 or not item.pricing_rules: continue + # Get all pricing rules applied to this item for pr_name in get_applied_pricing_rules(item.pricing_rules): + # Fetch and cache pricing rule if not already cached if pr_name not in pricing_rules_cache: pr = frappe.get_cached_doc("Pricing Rule", pr_name) + # Only cache rules that are Price-type Min/Max rules + # Other rules are handled by the standard pricing rule system if ( pr.price_or_product_discount == "Price" and pr.apply_discount_on_price in ("Min", "Max") ): pricing_rules_cache[pr_name] = pr else: + # Mark non-Min/Max rules as None to skip them pricing_rules_cache[pr_name] = None + # Add item to the rule's item list if it's a Min/Max rule if pricing_rules_cache[pr_name]: rule_items[pr_name].append(item) - return rule_items, pricing_rules_cache \ No newline at end of file + return rule_items, pricing_rules_cache From 661a95efa8a860ce050ee5ea3251edd13b2a231e Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Tue, 27 Jan 2026 11:06:33 +0200 Subject: [PATCH 09/14] FIX Critical Issues --- pos_next/hooks.py | 8 +------- pos_next/overrides/pricing_rule.py | 23 +++++++++++++++++++---- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/pos_next/hooks.py b/pos_next/hooks.py index 93fa1506..e498ae4a 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -138,7 +138,7 @@ # before_install = "pos_next.install.before_install" after_install = "pos_next.install.after_install" -after_migrate = "pos_next.install.after_migrate" +after_migrate = ["pos_next.install.after_migrate", "pos_next.overrides.pricing_rule.patch_pricing_rule"] # Uninstallation # ------------ @@ -252,12 +252,6 @@ "pos_next.tasks.branding_monitor.reset_tampering_counter", ], } -try: - from erpnext.accounts.doctype.pricing_rule import pricing_rule as pricing_rule_module - from pos_next.overrides.pricing_rule import apply_price_discount_rule - pricing_rule_module.apply_price_discount_rule = apply_price_discount_rule -except Exception as e: - frappe.log_error(frappe.get_traceback(), "Error in pricing rule module") # Testing # ------- diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index 46407b96..b811a35b 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -14,6 +14,7 @@ from erpnext.accounts.doctype.pricing_rule.pricing_rule import ( apply_price_discount_rule as _original_apply_price_discount_rule ) +from frappe import _ def apply_price_discount_rule(pricing_rule, item_details, args): @@ -44,6 +45,13 @@ def apply_price_discount_rule(pricing_rule, item_details, args): # For standard pricing rules, use the original implementation return _original_apply_price_discount_rule(pricing_rule, item_details, args) +def patch_pricing_rule(): + """Safely patch the pricing rule module after Frappe is initialized.""" + try: + from erpnext.accounts.doctype.pricing_rule import pricing_rule as pricing_rule_module + pricing_rule_module.apply_price_discount_rule = apply_price_discount_rule + except Exception: + frappe.log_error("Failed to patch pricing rule module for Min/Max discounts") def apply_min_max_price_discounts(doc, method=None): """ @@ -88,20 +96,27 @@ def apply_min_max_price_discounts(doc, method=None): # Track remaining quantity that can receive discount # This limit ensures only a specified quantity gets discounted - remaining_qty = flt(pr.min_or_max_discount_qty_limit or 0) + qty_limit = flt(pr.min_or_max_discount_qty_limit or 0) + has_qty_limit = qty_limit > 0 + remaining_qty = qty_limit if has_qty_limit else 0 # Apply discount to items in priority order (sorted by price) for item in items: base_rate = flt(item.price_list_rate) or flt(item.rate) if not base_rate: continue - + + # Unlimited discount -> apply fully + if not has_qty_limit: + if item == items[0]: + _apply_discount(pr, item, item.qty) + continue + + # If discount quantity limit exhausted, reset item to base price if remaining_qty <= 0: item.discount_percentage = 0.0 item.discount_amount = 0.0 - # item.rate = base_rate - # item.amount = item.rate * item.qty continue # Calculate how much of this item's quantity qualifies for discount From f75fc59b6acafb2e4b23e705416b8bc5044371fc Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Tue, 27 Jan 2026 11:47:21 +0200 Subject: [PATCH 10/14] FIX Medium Issues and Minor Issues --- pos_next/hooks.py | 1 - pos_next/overrides/pricing_rule.py | 77 ++++++++++++++++-------------- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/pos_next/hooks.py b/pos_next/hooks.py index e498ae4a..ce7b3795 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -1,5 +1,4 @@ from pos_next.utils import get_build_version -import frappe app_name = "pos_next" app_title = "POS Next" app_publisher = "BrainWise" diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index b811a35b..e632c2f8 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -18,32 +18,33 @@ def apply_price_discount_rule(pricing_rule, item_details, args): - """ - Override function to defer Min/Max discount application to a later stage. - - This function intercepts the standard pricing rule application process. - For Min/Max pricing rules, it skips the immediate discount application - because these rules require evaluating all items together to determine - which items qualify based on their relative prices. - - Args: - pricing_rule (dict): The pricing rule configuration dictionary - item_details (dict): Details about the item being processed - args (dict): Additional arguments for the pricing rule application - - Returns: - None: If the rule is Min/Max type (deferred to apply_min_max_price_discounts) - Any: Result from the original function for non-Min/Max rules - """ - apply_discount_on_price = pricing_rule.get("apply_discount_on_price") or "" - - # Defer Min/Max discount application - these require evaluating all items - # together to determine which items qualify based on price ranking - if apply_discount_on_price in ["Min", "Max"]: - return - - # For standard pricing rules, use the original implementation - return _original_apply_price_discount_rule(pricing_rule, item_details, args) + """ + Override function to defer Min/Max discount application to a later stage. + + This function intercepts the standard pricing rule application process. + For Min/Max pricing rules, it skips the immediate discount application + because these rules require evaluating all items together to determine + which items qualify based on their relative prices. + + Args: + pricing_rule (dict): The pricing rule configuration dictionary + item_details (dict): Details about the item being processed + args (dict): Additional arguments for the pricing rule application + + Returns: + None: If the rule is Min/Max type (deferred to apply_min_max_price_discounts) + Any: Result from the original function for non-Min/Max rules + """ + apply_discount_on_price = pricing_rule.get("apply_discount_on_price") or "" + + # Defer Min/Max discount application - these require evaluating all items + # together to determine which items qualify based on price ranking + if apply_discount_on_price in ["Min", "Max"]: + item_details.pricing_rule_for = pricing_rule.rate_or_discount + return + + # For standard pricing rules, use the original implementation + return _original_apply_price_discount_rule(pricing_rule, item_details, args) def patch_pricing_rule(): """Safely patch the pricing rule module after Frappe is initialized.""" @@ -77,9 +78,13 @@ def apply_min_max_price_discounts(doc, method=None): # Process each pricing rule separately for pr_name, items in rule_items.items(): pr = pricing_rules[pr_name] - + doc_price_list = ( + doc.get("selling_price_list") + or doc.get("buying_price_list") + or doc.get("price_list") + ) # Skip if pricing rule is restricted to a different price list - if pr.for_price_list and pr.for_price_list != doc.selling_price_list: + if pr.for_price_list and pr.for_price_list != doc_price_list: continue # Skip if no items match this rule @@ -121,10 +126,7 @@ def apply_min_max_price_discounts(doc, method=None): # Calculate how much of this item's quantity qualifies for discount # This ensures we don't exceed the total discount quantity limit - discount_qty = ( - item.qty if remaining_qty <= 0 - else min(item.qty, remaining_qty) - ) + discount_qty = min(flt(item.qty), remaining_qty) # Apply discount to the eligible quantity _apply_discount(pr, item, discount_qty) @@ -133,13 +135,14 @@ def apply_min_max_price_discounts(doc, method=None): # Recalculate totals after applying all discounts if hasattr(doc, "calculate_taxes_and_totals"): doc.calculate_taxes_and_totals() - - except Exception: + except frappe.ValidationError: + raise + except Exception as e: # Log errors but don't break the document processing flow - frappe.log_error(frappe.get_traceback(), "Min/Max Pricing Rule Failed") + frappe.log_error(frappe.get_traceback(), "Min/Max Pricing Rule Failed", e) frappe.throw( - _("Failed to apply pricing rule discounts. Please review pricing rules or contact support."), - title="Min/Max Pricing Rule Failed" + _("Failed to apply pricing rule discounts: {0}").format(str(e)), + title=_("Pricing Rule Error") ) From d206c5f9b25b28351ba88fc1cd24373c8fdc1785 Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Tue, 27 Jan 2026 12:42:44 +0200 Subject: [PATCH 11/14] - Modified return behavior to skip standard pricing rule application when Min/Max conditions are met. --- pos_next/overrides/pricing_rule.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index e632c2f8..aba2af7b 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -40,8 +40,12 @@ def apply_price_discount_rule(pricing_rule, item_details, args): # Defer Min/Max discount application - these require evaluating all items # together to determine which items qualify based on price ranking if apply_discount_on_price in ["Min", "Max"]: - item_details.pricing_rule_for = pricing_rule.rate_or_discount - return + # Set pricing_rule_for for reference, but don't apply discount here + # The discount will be applied later in apply_min_max_price_discounts + if hasattr(item_details, 'pricing_rule_for'): + item_details.pricing_rule_for = pricing_rule.get("rate_or_discount") + # Return None to skip standard pricing rule application + return None # For standard pricing rules, use the original implementation return _original_apply_price_discount_rule(pricing_rule, item_details, args) @@ -126,7 +130,7 @@ def apply_min_max_price_discounts(doc, method=None): # Calculate how much of this item's quantity qualifies for discount # This ensures we don't exceed the total discount quantity limit - discount_qty = min(flt(item.qty), remaining_qty) + discount_qty = min(flt(item.qty), remaining_qty) # Apply discount to the eligible quantity _apply_discount(pr, item, discount_qty) From 7e524a304f229d8c32699ea8ca16793173da0099 Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Tue, 27 Jan 2026 17:43:33 +0200 Subject: [PATCH 12/14] feat(cart): enhance offer validation and application process - Consolidated offer re-validation and auto-application into a single operation to ensure accurate updates for Min/Max rules and cart-wide discounts. - Implemented client-side checks for invalid offers and new eligible offers before server communication. - Improved server response handling to update applied offers and provide user feedback on offer status. - Added error handling for offer synchronization to maintain cart integrity during operations. --- POS/src/stores/posCart.js | 113 ++++++++++++++++++++++++++--- pos_next/api/invoices.py | 28 ++++++- pos_next/hooks.py | 8 +- pos_next/overrides/pricing_rule.py | 109 ++++++++++++---------------- 4 files changed, 182 insertions(+), 76 deletions(-) diff --git a/POS/src/stores/posCart.js b/POS/src/stores/posCart.js index b3930897..47ef0770 100644 --- a/POS/src/stores/posCart.js +++ b/POS/src/stores/posCart.js @@ -1493,21 +1493,110 @@ export const usePOSCartStore = defineStore("posCart", () => { currency: posProfile.value.currency, } - // Validate and auto-remove invalid offers (if any are applied) - if (appliedOffers.value.length > 0) { - await reapplyOffer(currentProfile, signal) - } + // Consolidate offer re-validation and auto-application into a single sequential operation. + // This ensures Min/Max rules and other cart-wide discounts are updated correctly + // when ANY cart change occurs (items, quantities, prices). + try { + // 1. Identify invalid offers to remove (client-side check) + const invalidOffers = [] + for (const entry of appliedOffers.value) { + if (entry.offer) { + const { eligible } = offersStore.checkOfferEligibility(entry.offer) + if (!eligible) invalidOffers.push(entry) + } + } - // Check cancellation before auto-apply - if (signal?.aborted) return + // 2. Identify new eligible offers to apply (client-side check) + const allEligibleOffers = offersStore.allEligibleOffers + const currentAppliedCodes = new Set(appliedOffers.value.map(o => o.code)) + const newOffers = allEligibleOffers.filter(offer => !currentAppliedCodes.has(offer.name)) + + // 3. Determine if we need to call the server + // We MUST hit the server if: + // - We have applied offers (calculations might change even if codes don't) + // - We have new auto-offers to apply + // - We have invalid offers to remove + const validExistingCodes = appliedOffers.value + .filter(o => !invalidOffers.find(inv => inv.code === o.code)) + .map(o => o.code) + + const newOfferCodes = newOffers.map(o => o.name) + const combinedCodes = [...new Set([...validExistingCodes, ...newOfferCodes])] + + if (combinedCodes.length > 0 || invalidOffers.length > 0) { + const invoiceData = buildOfferEvaluationPayload(currentProfile) + const response = await applyOffersResource.submit({ + invoice_data: invoiceData, + selected_offers: combinedCodes, + }) - // Check again if stale after reapply - if (generation > 0 && generation < cartGeneration) { - return - } + // Check for cancellation or stale operation + if (signal?.aborted || (generation > 0 && generation < cartGeneration)) return + + const { items: responseItems, freeItems, appliedRules } = parseOfferResponse(response) - // Auto-apply eligible offers (always check for new eligible offers) - await autoApplyEligibleOffers(currentProfile, signal) + // 4. Update cart items with new discounts + suppressOfferReapply.value = true + applyDiscountsFromServer(responseItems) + processFreeItems(freeItems) + + // 5. Update appliedOffers list based on server confirmation + const actuallyApplied = new Set(appliedRules) + const nextAppliedOffers = [] + const newlyAddedNames = [] + + // Handle existing ones + for (const entry of appliedOffers.value) { + if (!invalidOffers.find(inv => inv.code === entry.code) && actuallyApplied.has(entry.code)) { + nextAppliedOffers.push(entry) + } + } + + // Handle new ones + for (const offer of newOffers) { + if (actuallyApplied.has(offer.name)) { + nextAppliedOffers.push({ + name: offer.title || offer.name, + code: offer.name, + offer, + source: "auto", + applied: true, + rules: [offer.name], + min_qty: offer.min_qty, + max_qty: offer.max_qty, + min_amt: offer.min_amt, + max_amt: offer.max_amt, + }) + newlyAddedNames.push(offer.title || offer.name) + } + } + + appliedOffers.value = nextAppliedOffers + + // 6. UI Feedback + if (invalidOffers.length > 0) { + const names = invalidOffers.map(o => o.name).join(', ') + showWarning(__('Offer removed: {0}. Cart no longer meets requirements.', [names])) + } + + if (newlyAddedNames.length > 0) { + if (newlyAddedNames.length === 1) { + showSuccess(__('Offer applied: {0}', [newlyAddedNames[0]])) + } else { + showSuccess(__('Offers applied: {0}', [newlyAddedNames.join(', ')])) + } + } + } else if (invoiceItems.value.length === 0 && appliedOffers.value.length > 0) { + // Cart cleared, reset offers + appliedOffers.value = [] + processFreeItems([]) + rebuildIncrementalCache() + } + } catch (error) { + if (signal?.aborted) return + console.error("Error in offer synchronization:", error) + offerProcessingState.value.error = error.message + } // Update last processed hash on success offerProcessingState.value.lastCartHash = generateCartHash() diff --git a/pos_next/api/invoices.py b/pos_next/api/invoices.py index 8aec34ac..3f997531 100644 --- a/pos_next/api/invoices.py +++ b/pos_next/api/invoices.py @@ -240,7 +240,7 @@ def _validate_stock_on_invoice(invoice_doc): items_to_check = [d.as_dict() for d in invoice_doc.items if d.get("is_stock_item")] # Include packed items if present - if hasattr(invoice_doc, "packed_items"): + if getattr(invoice_doc, "packed_items", None): items_to_check.extend([d.as_dict() for d in invoice_doc.packed_items]) # Check for stock errors @@ -2272,7 +2272,7 @@ def apply_offers(invoice_data, selected_offers=None): # Include both promotional scheme rules and standalone pricing rules rule_map[record.name] = record - if selected_offer_names: + if selected_offers is not None: # Restrict available rules to the ones explicitly selected from the UI. rule_map = { name: details @@ -2402,6 +2402,30 @@ def apply_offers(invoice_data, selected_offers=None): ].promotional_scheme free_items.append(free_item_doc) + # ======================================================================== + # 2. APPLY MIN/MAX PRICING RULES (BULK EVALUATION) + # ======================================================================== + # These rules require evaluating all items together to determine which + # items qualify based on price ranking. They were skipped by the standard + # engine above and now we evaluate them across the entire cart. + # ======================================================================== + from pos_next.overrides.pricing_rule import apply_min_max_price_discounts + + # Create a mock document for the bulk evaluator + # It needs 'items' (list of dicts) and price list info + mock_doc = frappe._dict({ + "doctype": invoice.get("doctype") or "Sales Invoice", + "items": prepared_items, + "selling_price_list": pricing_args.price_list, + "company": pricing_args.company, + "customer": pricing_args.customer + }) + + # Determine allowed rules for Min/Max evaluator + allowed_rules = set(rule_map.keys()) if selected_offers is not None else None + + apply_min_max_price_discounts(mock_doc, allowed_rules=allowed_rules) + return { "items": [dict(item) for item in prepared_items], "free_items": [dict(item) for item in free_items], diff --git a/pos_next/hooks.py b/pos_next/hooks.py index ce7b3795..f0fce200 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -5,6 +5,7 @@ app_description = "POS built on ERPNext that brings together real-time billing, stock management, multi-user access, offline mode, and direct ERP integration. Run your store or restaurant with confidence and control, while staying 100% open source." app_email = "support@brainwise.me" app_license = "agpl-3.0" +import frappe # Apps # ------------------ @@ -189,7 +190,12 @@ # DocType Class # --------------- # Override standard doctype classes - +try: + from erpnext.accounts.doctype.pricing_rule import pricing_rule as pricing_rule_module + from pos_next.overrides.pricing_rule import apply_price_discount_rule + pricing_rule_module.apply_price_discount_rule = apply_price_discount_rule +except Exception as e: + frappe.log_error(frappe.get_traceback(), "Error in pricing rule module") override_doctype_class = { "Sales Invoice": "pos_next.overrides.sales_invoice.CustomSalesInvoice" } diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index aba2af7b..79256e5e 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -58,7 +58,7 @@ def patch_pricing_rule(): except Exception: frappe.log_error("Failed to patch pricing rule module for Min/Max discounts") -def apply_min_max_price_discounts(doc, method=None): +def apply_min_max_price_discounts(doc, method=None, allowed_rules=None): """ Apply Min/Max pricing rule discounts to document items. @@ -72,8 +72,9 @@ def apply_min_max_price_discounts(doc, method=None): - For "Max" rules: Items with highest prices get discounted first Args: - doc: The document (e.g., Sales Invoice, Quotation) containing items - method (str, optional): Hook method name if called via Frappe hooks + doc: Document or dict containing items + method: Hook method name (optional) + allowed_rules: Optional list/set of rule names to restrict application to """ try: # Collect all items that have Min/Max pricing rules applied @@ -81,7 +82,14 @@ def apply_min_max_price_discounts(doc, method=None): # Process each pricing rule separately for pr_name, items in rule_items.items(): + # Respect allowed_rules filter if provided + if allowed_rules is not None and pr_name not in allowed_rules: + continue + pr = pricing_rules[pr_name] + if not pr: + continue + doc_price_list = ( doc.get("selling_price_list") or doc.get("buying_price_list") @@ -91,10 +99,6 @@ def apply_min_max_price_discounts(doc, method=None): if pr.for_price_list and pr.for_price_list != doc_price_list: continue - # Skip if no items match this rule - if not items: - continue - # Determine sort direction: Max rules need descending order (highest first) # Min rules need ascending order (lowest first) reverse = pr.apply_discount_on_price == "Max" @@ -104,7 +108,6 @@ def apply_min_max_price_discounts(doc, method=None): ) # Track remaining quantity that can receive discount - # This limit ensures only a specified quantity gets discounted qty_limit = flt(pr.min_or_max_discount_qty_limit or 0) has_qty_limit = qty_limit > 0 remaining_qty = qty_limit if has_qty_limit else 0 @@ -112,98 +115,81 @@ def apply_min_max_price_discounts(doc, method=None): # Apply discount to items in priority order (sorted by price) for item in items: base_rate = flt(item.price_list_rate) or flt(item.rate) - if not base_rate: + qty = flt(item.qty) + if not base_rate or qty <= 0: continue - # Unlimited discount -> apply fully + # Unlimited discount to first item only if no limit is set if not has_qty_limit: if item == items[0]: - _apply_discount(pr, item, item.qty) - continue + _apply_discount(pr, item, qty) + else: + # Reset other items that might have had discounts + item.discount_percentage = 0.0 + item.discount_amount = 0.0 + item.rate = base_rate + item.amount = base_rate * qty + continue - # If discount quantity limit exhausted, reset item to base price if remaining_qty <= 0: item.discount_percentage = 0.0 item.discount_amount = 0.0 + item.rate = base_rate + item.amount = base_rate * qty continue # Calculate how much of this item's quantity qualifies for discount - # This ensures we don't exceed the total discount quantity limit - discount_qty = min(flt(item.qty), remaining_qty) + discount_qty = min(qty, remaining_qty) # Apply discount to the eligible quantity _apply_discount(pr, item, discount_qty) remaining_qty -= discount_qty # Recalculate totals after applying all discounts - if hasattr(doc, "calculate_taxes_and_totals"): + if hasattr(doc, "calculate_taxes_and_totals") and callable(doc.calculate_taxes_and_totals): doc.calculate_taxes_and_totals() - except frappe.ValidationError: - raise except Exception as e: - # Log errors but don't break the document processing flow - frappe.log_error(frappe.get_traceback(), "Min/Max Pricing Rule Failed", e) - frappe.throw( - _("Failed to apply pricing rule discounts: {0}").format(str(e)), - title=_("Pricing Rule Error") - ) + frappe.log_error(frappe.get_traceback(), "Min/Max Pricing Rule Failed") + if not frappe.flags.in_test: + frappe.msgprint(_("Warning: Min/Max pricing rules could not be fully applied."), indicator="orange") def _apply_discount(pr, item, eligible_qty): """ Apply discount to an item based on the pricing rule configuration. - - This helper function calculates and applies the discount amount based on - the pricing rule's discount type (Rate, Discount Percentage, or Discount Amount). - It ensures that discounts never result in negative item rates. - - Args: - pr: The Pricing Rule document - item: The item row to apply discount to - eligible_qty (float): The quantity of this item that qualifies for discount + Ensures mathematical accuracy where rate * qty = amount. """ base_rate = flt(item.price_list_rate) or flt(item.rate) - if not base_rate or eligible_qty <= 0: + qty = flt(item.qty) + + if not base_rate or eligible_qty <= 0 or qty <= 0: return - qty = flt(item.qty) # Calculate maximum possible discount to prevent negative rates - # This ensures the discount never exceeds the item's base value base_discount_cap = base_rate * eligible_qty total_discount = 0.0 # Calculate discount based on pricing rule type if pr.rate_or_discount == "Rate" and pr.rate: - # Fixed rate: discount is the difference between base rate and rule rate total_discount = (base_rate - flt(pr.rate)) * eligible_qty - elif pr.rate_or_discount == "Discount Percentage" and pr.discount_percentage: - # Percentage discount: calculate per unit, then multiply by eligible quantity - per_unit_discount = base_rate * flt(pr.discount_percentage) / 100 - total_discount = per_unit_discount * eligible_qty - + total_discount = (base_rate * flt(pr.discount_percentage) / 100) * eligible_qty elif pr.rate_or_discount == "Discount Amount" and pr.discount_amount: - # Fixed amount discount: apply per unit of eligible quantity total_discount = flt(pr.discount_amount) * eligible_qty - # Enforce discount cap to prevent negative rates + # Enforce discount cap total_discount = min(total_discount, base_discount_cap) - - # Calculate effective rate after discount - # Note: discount is applied to eligible_qty, but rate is calculated per total qty - effective_rate = (base_rate * qty - total_discount) / qty - - # Update item fields with calculated values - item.rate = max(effective_rate, 0.0) # Ensure rate is never negative + + # Mathematical accuracy: Set amount first, then derive rate + # This ensures (rate * qty) exactly matches (base_value - total_discount) + total_line_value = base_rate * qty + net_amount = max(total_line_value - total_discount, 0.0) + + item.amount = net_amount + item.rate = net_amount / qty item.discount_amount = total_discount - # Calculate discount percentage based on total item value - item.discount_percentage = ( - total_discount / (base_rate * qty) - ) * 100 if base_rate * qty else 0.0 - - # Recalculate item amount with new rate - item.amount = item.rate * qty + item.discount_percentage = (total_discount / total_line_value * 100) if total_line_value else 0.0 def _collect_min_max_rule_items(doc): @@ -227,13 +213,14 @@ def _collect_min_max_rule_items(doc): # Cache pricing rule documents to avoid redundant database queries pricing_rules_cache = {} - for item in doc.items: + items = doc.get("items") or [] + for item in items: # Skip free items, items with zero/negative quantity, or items without pricing rules - if item.is_free_item or flt(item.qty) <= 0 or not item.pricing_rules: + if item.get("is_free_item") or flt(item.get("qty")) <= 0 or not item.get("pricing_rules"): continue # Get all pricing rules applied to this item - for pr_name in get_applied_pricing_rules(item.pricing_rules): + for pr_name in get_applied_pricing_rules(item.get("pricing_rules")): # Fetch and cache pricing rule if not already cached if pr_name not in pricing_rules_cache: pr = frappe.get_cached_doc("Pricing Rule", pr_name) From b2698e6491819d2420e67784cf4e30a32a5523bd Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Wed, 28 Jan 2026 12:14:16 +0200 Subject: [PATCH 13/14] refactor(pricing): go with moky patching --- pos_next/hooks.py | 33 +++++++++++++++++++++--------- pos_next/overrides/pricing_rule.py | 8 -------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/pos_next/hooks.py b/pos_next/hooks.py index f0fce200..e95ccd23 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -5,8 +5,6 @@ app_description = "POS built on ERPNext that brings together real-time billing, stock management, multi-user access, offline mode, and direct ERP integration. Run your store or restaurant with confidence and control, while staying 100% open source." app_email = "support@brainwise.me" app_license = "agpl-3.0" -import frappe - # Apps # ------------------ @@ -138,7 +136,7 @@ # before_install = "pos_next.install.before_install" after_install = "pos_next.install.after_install" -after_migrate = ["pos_next.install.after_migrate", "pos_next.overrides.pricing_rule.patch_pricing_rule"] +after_migrate = "pos_next.install.after_migrate" # Uninstallation # ------------ @@ -190,12 +188,6 @@ # DocType Class # --------------- # Override standard doctype classes -try: - from erpnext.accounts.doctype.pricing_rule import pricing_rule as pricing_rule_module - from pos_next.overrides.pricing_rule import apply_price_discount_rule - pricing_rule_module.apply_price_discount_rule = apply_price_discount_rule -except Exception as e: - frappe.log_error(frappe.get_traceback(), "Error in pricing rule module") override_doctype_class = { "Sales Invoice": "pos_next.overrides.sales_invoice.CustomSalesInvoice" } @@ -239,6 +231,21 @@ }, "POS Invoice": { "validate": "pos_next.overrides.pricing_rule.apply_min_max_price_discounts" + }, + "Purchase Order": { + "validate": "pos_next.overrides.pricing_rule.apply_min_max_price_discounts" + }, + "Supplier Quotation": { + "validate": "pos_next.overrides.pricing_rule.apply_min_max_price_discounts" + }, + "Purchase Receipt": { + "validate": "pos_next.overrides.pricing_rule.apply_min_max_price_discounts" + }, + "Purchase Invoice": { + "validate": "pos_next.overrides.pricing_rule.apply_min_max_price_discounts" + }, + "Opportunity": { + "validate": "pos_next.overrides.pricing_rule.apply_min_max_price_discounts" } } @@ -264,7 +271,13 @@ # Overriding Methods # ------------------------------ -# +try: + from erpnext.accounts.doctype.pricing_rule import pricing_rule as erpnext_pricing_rule + from pos_next.overrides.pricing_rule import apply_price_discount_rule as pos_next_apply_price_discount_rule + erpnext_pricing_rule.apply_price_discount_rule = pos_next_apply_price_discount_rule +except: + import frappe + frappe.log_error(frappe.get_traceback(), "Pricing Rule Override Error") # override_whitelisted_methods = { # "frappe.desk.doctype.event.event.get_events": "pos_next.event.get_events" # } diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index 79256e5e..32ff3ec5 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -50,14 +50,6 @@ def apply_price_discount_rule(pricing_rule, item_details, args): # For standard pricing rules, use the original implementation return _original_apply_price_discount_rule(pricing_rule, item_details, args) -def patch_pricing_rule(): - """Safely patch the pricing rule module after Frappe is initialized.""" - try: - from erpnext.accounts.doctype.pricing_rule import pricing_rule as pricing_rule_module - pricing_rule_module.apply_price_discount_rule = apply_price_discount_rule - except Exception: - frappe.log_error("Failed to patch pricing rule module for Min/Max discounts") - def apply_min_max_price_discounts(doc, method=None, allowed_rules=None): """ Apply Min/Max pricing rule discounts to document items. From c49b87739a5f971194aaa88ed1813808366a699c Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Wed, 28 Jan 2026 13:23:34 +0200 Subject: [PATCH 14/14] refactor(pricing): streamline discount calculation and item update - Simplified the discount calculation logic by directly updating item attributes in a single operation. --- pos_next/overrides/pricing_rule.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index 32ff3ec5..eaddd3e1 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -159,30 +159,27 @@ def _apply_discount(pr, item, eligible_qty): return # Calculate maximum possible discount to prevent negative rates - base_discount_cap = base_rate * eligible_qty total_discount = 0.0 - - # Calculate discount based on pricing rule type - if pr.rate_or_discount == "Rate" and pr.rate: + if pr.rate_or_discount == "Rate": total_discount = (base_rate - flt(pr.rate)) * eligible_qty - elif pr.rate_or_discount == "Discount Percentage" and pr.discount_percentage: + elif pr.rate_or_discount == "Discount Percentage": total_discount = (base_rate * flt(pr.discount_percentage) / 100) * eligible_qty - elif pr.rate_or_discount == "Discount Amount" and pr.discount_amount: + elif pr.rate_or_discount == "Discount Amount": total_discount = flt(pr.discount_amount) * eligible_qty - - # Enforce discount cap - total_discount = min(total_discount, base_discount_cap) # Mathematical accuracy: Set amount first, then derive rate # This ensures (rate * qty) exactly matches (base_value - total_discount) total_line_value = base_rate * qty net_amount = max(total_line_value - total_discount, 0.0) - item.amount = net_amount - item.rate = net_amount / qty - item.discount_amount = total_discount - item.discount_percentage = (total_discount / total_line_value * 100) if total_line_value else 0.0 - + item.update({ + "amount": net_amount, + "rate": net_amount / qty if qty else 0, + "discount_amount": total_line_value - net_amount, + "discount_percentage": ((total_line_value - net_amount) / total_line_value * 100) if total_line_value else 0, + "pricing_rule": pr.name, + "pricing_rule_for": pr.rate_or_discount + }) def _collect_min_max_rule_items(doc): """