From cbc746fbffaf9190ef63acbe72a8447b5a94054b Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Sun, 28 Jun 2026 17:30:27 +0300 Subject: [PATCH 1/2] feat: implement min/max discount logic for pricing rules and promotional schemes --- POS/src/stores/posCart.js | 8 +- POS/src/stores/posOffers.js | 9 +- pos_next/__init__.py | 24 ++ pos_next/api/invoices.py | 35 +++ pos_next/api/offers.py | 12 +- pos_next/hooks.py | 17 +- pos_next/overrides/pricing_rule.py | 290 ++++++++++++++++++ pos_next/pos_next/custom/pricing_rule.json | 92 ++++++ .../promotional_scheme_price_discount.json | 101 ++++++ pos_next/public/js/pricing_rule.js | 46 +++ pos_next/public/js/promotional_scheme.js | 48 +++ 11 files changed, 673 insertions(+), 9 deletions(-) create mode 100644 pos_next/pos_next/custom/promotional_scheme_price_discount.json create mode 100644 pos_next/public/js/pricing_rule.js create mode 100644 pos_next/public/js/promotional_scheme.js diff --git a/POS/src/stores/posCart.js b/POS/src/stores/posCart.js index 6b221a7a5..8d451c946 100644 --- a/POS/src/stores/posCart.js +++ b/POS/src/stores/posCart.js @@ -953,9 +953,11 @@ export const usePOSCartStore = defineStore("posCart", () => { ); } else if (offer.apply_on === "Item Group") { const eligibleGroups = offer.eligible_item_groups || []; - eligibleItems = invoiceItems.value.filter((item) => - eligibleGroups.includes(item.item_group) - ); + eligibleItems = eligibleGroups.includes("All Item Groups") + ? invoiceItems.value + : invoiceItems.value.filter((item) => + eligibleGroups.includes(item.item_group) + ); } else if (offer.apply_on === "Brand") { const eligibleBrands = offer.eligible_brands || []; eligibleItems = invoiceItems.value.filter((item) => diff --git a/POS/src/stores/posOffers.js b/POS/src/stores/posOffers.js index 210018ffb..b11eb3163 100644 --- a/POS/src/stores/posOffers.js +++ b/POS/src/stores/posOffers.js @@ -181,6 +181,9 @@ export const usePOSOffersStore = defineStore("posOffers", () => { } else if (offer?.apply_on === "Item Group") { const eligibleGroups = offer.eligible_item_groups || []; if (eligibleGroups.length > 0) { + if (eligibleGroups.includes("All Item Groups")) { + return cartSnapshot.value.itemCount || 0; + } // Sum quantities of all items in eligible groups return eligibleGroups.reduce((sum, group) => { return sum + (itemGroupQuantities[group] || 0); @@ -259,9 +262,9 @@ export const usePOSOffersStore = defineStore("posOffers", () => { } else if (offer?.apply_on === "Item Group") { const eligibleGroups = offer.eligible_item_groups || []; if (eligibleGroups.length > 0) { - const hasEligibleGroup = eligibleGroups.some((group) => - cartItemGroups.includes(group) - ); + const hasEligibleGroup = + eligibleGroups.includes("All Item Groups") || + eligibleGroups.some((group) => cartItemGroups.includes(group)); if (!hasEligibleGroup) { return { eligible: false, diff --git a/pos_next/__init__.py b/pos_next/__init__.py index 3376ded4c..9130b6a1e 100644 --- a/pos_next/__init__.py +++ b/pos_next/__init__.py @@ -25,6 +25,30 @@ def console(*data): except Exception: pass + +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 Exception: + if frappe: + frappe.log_error(frappe.get_traceback(), "Pricing Rule Override Error") + + +try: + from erpnext.accounts.doctype.promotional_scheme import promotional_scheme as _promotional_scheme + + for _min_max_field in ("apply_discount_on_price", "min_or_max_discount_qty_limit"): + if _min_max_field not in _promotional_scheme.price_discount_fields: + _promotional_scheme.price_discount_fields.append(_min_max_field) +except Exception: + if frappe: + frappe.log_error(frappe.get_traceback(), "Promotional Scheme Field Patch Error") + # Frappe/ERPNext compatibility shim: # ERPNext may pass do_not_round_fields to round_floats_in, but older Frappe # versions don't accept that kwarg. diff --git a/pos_next/api/invoices.py b/pos_next/api/invoices.py index 1b6b34919..f927a6ebf 100644 --- a/pos_next/api/invoices.py +++ b/pos_next/api/invoices.py @@ -43,10 +43,12 @@ from erpnext.accounts.doctype.pricing_rule.utils import ( get_applied_pricing_rules as erpnext_get_applied_pricing_rules, ) + from pos_next.overrides.pricing_rule import apply_min_max_price_discounts except Exception: # pragma: no cover - ERPNext not installed in some environments erpnext_apply_pricing_rule = None erpnext_get_applied_pricing_rules = None erpnext_apply_pricing_rule_on_transaction = None + apply_min_max_price_discounts = None # ========================================== @@ -3205,6 +3207,12 @@ def apply_offers(invoice_data, selected_offers=None): # Fetch full pricing rule to get discount values full_rule = frappe.get_cached_doc("Pricing Rule", rule_name) + # Min/Max rules are deferred to apply_min_max_price_discounts + # (cross-item ranking). Applying them here would discount every + # matching item, defeating the "cheapest/most-expensive" logic. + if full_rule.get("apply_discount_on_price") in ("Min", "Max"): + continue + if full_rule.rate_or_discount == "Discount Percentage" and full_rule.discount_percentage: discount_percentage += flt(full_rule.discount_percentage) elif full_rule.rate_or_discount == "Discount Amount" and full_rule.discount_amount: @@ -3272,6 +3280,33 @@ def apply_offers(invoice_data, selected_offers=None): free_items_map.setdefault(key, free_item_doc) applied_rules.update(txn_result.get("applied_rules", set())) + # Apply Min/Max ("cheapest/most-expensive item") price rules. These were + # deferred by the per-item engine (see pos_next.overrides.pricing_rule) and + # need a cross-item ranking pass over the whole cart. The mock doc has no + # calculate_taxes_and_totals(); the post-processor materialises rate/amount + # on each discounted item directly. + if apply_min_max_price_discounts: + 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, + } + ) + min_max_allowed = set(rule_map) if selected_offer_names else None + apply_min_max_price_discounts(mock_doc, allowed_rules=min_max_allowed) + + # Surface Min/Max rules in the response so the frontend tracks them as applied. + if erpnext_get_applied_pricing_rules: + for prepared_item in prepared_items: + if not prepared_item.get("pricing_rules"): + continue + for pr_name in erpnext_get_applied_pricing_rules(prepared_item.get("pricing_rules")): + if pr_name in rule_map: + applied_rules.add(pr_name) + return { "items": [dict(item) for item in prepared_items], "free_items": [dict(item) for item in free_items_map.values()], diff --git a/pos_next/api/offers.py b/pos_next/api/offers.py index 9c83099b5..70b48bf27 100644 --- a/pos_next/api/offers.py +++ b/pos_next/api/offers.py @@ -13,7 +13,7 @@ import frappe from frappe import _ -from frappe.utils import flt, getdate, nowdate +from frappe.utils import cint, flt, getdate, nowdate # ============================================================================ # Constants @@ -76,6 +76,8 @@ class Offer: rate: float discount_amount: float discount_percentage: float + apply_discount_on_price: str | None + min_or_max_discount_qty_limit: int valid_from: str | None valid_upto: str | None source: str @@ -242,6 +244,7 @@ def fetch_price_slabs(scheme_names: list[str]) -> dict[str, dict]: SELECT parent, min_qty, max_qty, min_amount, max_amount, rate_or_discount, rate, discount_amount, discount_percentage, + apply_discount_on_price, min_or_max_discount_qty_limit, apply_multiple_pricing_rules FROM `tabPromotional Scheme Price Discount` WHERE parent IN %s AND disable = 0 @@ -338,6 +341,10 @@ def build_from_scheme_rule(rule: dict, slab: dict, eligibility: OfferEligibility rate=flt(slab.get("rate", 0)) if is_price_discount else 0, discount_amount=flt(slab.get("discount_amount", 0)) if is_price_discount else 0, discount_percentage=flt(slab.get("discount_percentage", 0)) if is_price_discount else 0, + apply_discount_on_price=(slab.get("apply_discount_on_price") if is_price_discount else None), + min_or_max_discount_qty_limit=( + cint(slab.get("min_or_max_discount_qty_limit", 0)) if is_price_discount else 0 + ), valid_from=rule.get("valid_from"), valid_upto=rule.get("valid_upto"), source=OfferSource.PROMOTIONAL_SCHEME, @@ -392,6 +399,8 @@ def build_from_standalone_rule(rule: dict, eligibility: OfferEligibility) -> Off rate=flt(rule.get("rate", 0)), discount_amount=flt(rule.get("discount_amount", 0)), discount_percentage=flt(rule.get("discount_percentage", 0)), + apply_discount_on_price=rule.get("apply_discount_on_price"), + min_or_max_discount_qty_limit=cint(rule.get("min_or_max_discount_qty_limit", 0)), valid_from=rule.get("valid_from"), valid_upto=rule.get("valid_upto"), source=OfferSource.PRICING_RULE, @@ -530,6 +539,7 @@ def _get_standalone_pricing_rule_offers(company: str, date: str) -> list[Offer]: name, title, apply_on, selling, coupon_code_based, one_time_per_customer, price_or_product_discount, rate_or_discount, rate, discount_amount, discount_percentage, + apply_discount_on_price, min_or_max_discount_qty_limit, min_qty, max_qty, min_amt, max_amt, priority, valid_from, valid_upto FROM `tabPricing Rule` diff --git a/pos_next/hooks.py b/pos_next/hooks.py index 205d08879..534680d70 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -48,7 +48,11 @@ # page_js = {"page" : "public/js/file.js"} # include js in doctype views -doctype_js = {"Customer": "public/js/customer.js"} +doctype_js = { + "Customer": "public/js/customer.js", + "Pricing Rule": "public/js/pricing_rule.js", + "Promotional Scheme": "public/js/promotional_scheme.js", +} # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} @@ -151,6 +155,7 @@ "validate": [ "pos_next.api.sales_invoice_hooks.validate", "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": [ @@ -165,7 +170,15 @@ "after_insert": "pos_next.realtime_events.emit_invoice_created_event", }, "POS Profile": {"on_update": "pos_next.realtime_events.emit_pos_profile_updated_event"}, - "Promotional Scheme": {"on_update": "pos_next.overrides.pricing_rule.sync_pos_only_to_pricing_rules"}, + "Promotional Scheme": { + "validate": "pos_next.overrides.pricing_rule.enforce_min_max_pricing_config", + "on_update": "pos_next.overrides.pricing_rule.sync_pos_only_to_pricing_rules", + }, + "Pricing Rule": {"validate": "pos_next.overrides.pricing_rule.enforce_min_max_pricing_config"}, + "Sales Order": {"validate": "pos_next.overrides.pricing_rule.apply_min_max_price_discounts"}, + "Quotation": {"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"}, } # Scheduled Tasks diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index 6ed6901f7..c3e1c245e 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -9,9 +9,30 @@ (Sales Invoice with is_pos=1, or POS Invoice). Non-POS documents like Quotations, Sales Orders, Delivery Notes, and regular Sales Invoices will have these rules excluded from matching. + +Min/Max Price Discounts +----------------------- +A Price-type Pricing Rule may set ``apply_discount_on_price`` to ``Min`` or +``Max`` so the discount lands only on the cheapest (Min) or most expensive +(Max) item(s) carrying that rule, capped by ``min_or_max_discount_qty_limit``. +ERPNext's per-item engine cannot rank items against each other, so we suppress +its application of these rules (``apply_price_discount_rule`` below) and apply +them in a single bulk pass (``apply_min_max_price_discounts``) instead. """ +from collections import defaultdict + import frappe +from frappe import _ +from frappe.utils import flt + +from erpnext.accounts.doctype.pricing_rule.pricing_rule import ( + apply_price_discount_rule as _original_apply_price_discount_rule, +) +from erpnext.accounts.doctype.pricing_rule.utils import get_applied_pricing_rules + +# Values of the ``apply_discount_on_price`` custom field that trigger ranking. +MIN_MAX_OPTIONS = ("Min", "Max") def _has_pos_only_column(): @@ -89,3 +110,272 @@ def _patched_get_other_conditions(conditions, values, args): return conditions pr_utils.get_other_conditions = _patched_get_other_conditions + + +# --------------------------------------------------------------------------- +# Min/Max price discounts +# --------------------------------------------------------------------------- + + +def enforce_min_max_pricing_config(doc, method=None): + """Validate-time guard for Min/Max price rules. + + A Min/Max ("cheapest / most expensive item") discount only makes sense when the + engine evaluates the whole document together, so we force ``mixed_conditions`` + on (ERPNext otherwise gates each line independently and a one-of-each cart never + qualifies). We also reject a non-positive quantity limit, which would mean + "discount nothing". + + Wired as a ``validate`` doc_event for both **Promotional Scheme** (Min/Max lives + on the price-discount slabs and the generated rules inherit ``mixed_conditions``) + and **Pricing Rule** (standalone or scheme-generated). + """ + if doc.doctype == "Promotional Scheme": + min_max_slabs = [ + slab + for slab in (doc.get("price_discount_slabs") or []) + if slab.get("apply_discount_on_price") in MIN_MAX_OPTIONS + ] + if not min_max_slabs: + return + doc.mixed_conditions = 1 + for slab in min_max_slabs: + if flt(slab.get("min_or_max_discount_qty_limit")) < 1: + frappe.throw( + _( + "Set Min/Max Discount Qty Limit to at least 1 on the price " + "discount row using {0} discount." + ).format(slab.get("apply_discount_on_price")) + ) + return + + # Pricing Rule + if doc.get("apply_discount_on_price") not in MIN_MAX_OPTIONS: + return + doc.mixed_conditions = 1 + if flt(doc.get("min_or_max_discount_qty_limit")) < 1: + frappe.throw( + _( + "Set Min/Max Discount Qty Limit to at least 1 when " + "Apply Discount On is {0}." + ).format(doc.get("apply_discount_on_price")) + ) + + +def apply_price_discount_rule(pricing_rule, item_details, args): + """Override of ERPNext's ``apply_price_discount_rule`` (installed via monkey-patch). + + For ``Min``/``Max`` rules we *defer* the discount: the per-item engine cannot + know which items are the cheapest/most expensive across the whole cart, so we + suppress application here and let :func:`apply_min_max_price_discounts` apply + it later. We still mirror the original's bookkeeping (``pricing_rule_for`` and + margin handling) so nothing else downstream changes. + + All non-Min/Max rules fall through to ERPNext's original implementation. + """ + if (pricing_rule.get("apply_discount_on_price") or "") in MIN_MAX_OPTIONS: + # Keep parity with the original function's side effects. + item_details.pricing_rule_for = pricing_rule.get("rate_or_discount") + + margin_type = pricing_rule.get("margin_type") + if ( + margin_type in ["Amount", "Percentage"] + and pricing_rule.get("currency") == args.get("currency") + ) or margin_type == "Percentage": + item_details.margin_type = margin_type + item_details.has_margin = True + if ( + pricing_rule.get("apply_multiple_pricing_rules") + and item_details.get("margin_rate_or_amount") is not None + ): + item_details.margin_rate_or_amount += pricing_rule.get("margin_rate_or_amount") + else: + item_details.margin_rate_or_amount = pricing_rule.get("margin_rate_or_amount") + + # Return None: skip the standard discount application for this rule. + return None + + return _original_apply_price_discount_rule(pricing_rule, item_details, args) + + +def apply_min_max_price_discounts(doc, method=None, allowed_rules=None): + """Apply ``Min``/``Max`` price rules across the whole cart. + + Used both as a ``doc_events`` ``validate`` hook (real documents) and from the + POS ``apply_offers`` API (lightweight mock document). For each Min/Max rule it + ranks the items carrying that rule by price and discounts only those within the + configured quantity limit; items that do not qualify are left untouched so any + discount applied by *other* rules survives. + + Limitation: on an item that *wins* the Min/Max ranking, the blended Min/Max + percentage replaces (does not stack on top of) any discount another rule gave + that same item. Combining ``apply_multiple_pricing_rules`` with Min/Max on the + same item is therefore not supported. + + Args: + doc: Document (or ``frappe._dict`` mock) exposing ``items`` and price-list info. + method: Unused hook signature argument. + allowed_rules: Optional iterable of rule names; when given, only those rules + are applied (used by the POS UI to honour explicitly selected offers). + """ + try: + rule_items, pricing_rules = _collect_min_max_rule_items(doc) + if not rule_items: + return + + doc_price_list = ( + doc.get("selling_price_list") or doc.get("buying_price_list") or doc.get("price_list") + ) + + for pr_name, items in rule_items.items(): + if allowed_rules is not None and pr_name not in allowed_rules: + continue + + pr = pricing_rules.get(pr_name) + if not pr: + continue + + # A rule scoped to a specific price list must not bleed onto other lists. + if pr.get("for_price_list") and pr.get("for_price_list") != doc_price_list: + continue + + # Min => cheapest first (ascending); Max => most expensive first (descending). + reverse = pr.get("apply_discount_on_price") == "Max" + items.sort(key=lambda i: flt(i.get("price_list_rate")), reverse=reverse) + + # Number of units to discount among the ranked items. Empty/0 is + # meaningless for a "cheapest/most-expensive item" discount, so it + # defaults to a single unit (an unlimited limit would discount every + # unit of every matching item, i.e. an ordinary discount). + qty_limit = flt(pr.get("min_or_max_discount_qty_limit") or 0) + if qty_limit <= 0: + qty_limit = 1.0 + + remaining_qty = qty_limit + for item in items: + if remaining_qty <= 0: + break + eligible_qty = min(_item_qty(item), remaining_qty) + if eligible_qty <= 0: + continue + _apply_discount(pr, item, eligible_qty) + remaining_qty -= eligible_qty + + # Real documents must recalculate: our validate hook runs *after* the + # controller's calculate_taxes_and_totals(), so totals are stale until we + # refresh them. The POS mock has no such method (values are materialised in + # _apply_discount instead). + if hasattr(doc, "calculate_taxes_and_totals") and callable(doc.calculate_taxes_and_totals): + doc.calculate_taxes_and_totals() + except Exception: + frappe.log_error(frappe.get_traceback(), "Min/Max Pricing Rule Failed") + if not frappe.flags.in_test: + frappe.msgprint( + _("Some Min/Max pricing rules could not be applied. Please review the cart discounts."), + indicator="orange", + ) + + +def _apply_discount(pr, item, eligible_qty): + """Discount ``eligible_qty`` units of ``item`` under pricing rule ``pr``. + + We set *only* the blended ``discount_percentage`` (plus ``price_list_rate``) + and let ERPNext's ``calculate_item_values`` derive ``rate``/``amount``/ + ``discount_amount`` from it (taxes_and_totals.py). This keeps all the money + math owned by core and identical across every document type. The same formula + is materialised inline for the POS mock, which has no recalculation step. + + ``price_list_rate`` (never the already-discounted ``rate``) is the basis, so + re-running validate is idempotent rather than compounding. + """ + base_rate = flt(item.get("price_list_rate")) + qty = _item_qty(item) + if base_rate <= 0 or qty <= 0 or eligible_qty <= 0: + return + + rate_or_discount = pr.get("rate_or_discount") + if rate_or_discount == "Rate": + total_discount = (base_rate - flt(pr.get("rate"))) * eligible_qty + elif rate_or_discount == "Discount Percentage": + total_discount = base_rate * flt(pr.get("discount_percentage")) / 100.0 * eligible_qty + elif rate_or_discount == "Discount Amount": + total_discount = flt(pr.get("discount_amount")) * eligible_qty + else: + return + + line_value = base_rate * qty + # Clamp: never negative (e.g. a Rate higher than base) and never beyond the line. + total_discount = min(max(total_discount, 0.0), line_value) + if total_discount <= 0: + return + + # Per-unit-equivalent percentage: reproduces the right line total even when + # only part of the quantity is eligible (e.g. 4 units, limit 2). + blended_pct = total_discount / line_value * 100.0 + + item.price_list_rate = base_rate + item.discount_percentage = blended_pct + _materialize_rate(item, base_rate, blended_pct, qty) + + +def _materialize_rate(item, base_rate, discount_percentage, qty): + """Fill ``rate``/``amount``/``discount_amount`` using ERPNext's own formula. + + Mirrors ``calculate_item_values`` (taxes_and_totals.py): ``rate`` is + ``price_list_rate`` less the blended percentage and ``discount_amount`` is + per-unit. Needed for the POS path (no recalc); harmless on real documents + because ``calculate_taxes_and_totals`` recomputes the same values afterwards. + """ + precision = _rate_precision(item) + rate = flt(base_rate * (1.0 - discount_percentage / 100.0), precision) + item.rate = rate + item.discount_amount = flt(base_rate - rate, precision) + item.amount = flt(rate * qty, precision) + + +def _rate_precision(item): + """Best-effort currency precision for ``rate``; defaults to 2 for plain dicts.""" + getter = getattr(item, "precision", None) + if callable(getter): + try: + return getter("rate") or 2 + except Exception: + return 2 + return 2 + + +def _item_qty(item): + """Quantity for an item, tolerating the POS payload's ``quantity`` alias.""" + return flt(item.get("qty") or item.get("quantity") or 0) + + +def _collect_min_max_rule_items(doc): + """Group cart items by the Min/Max Price rule(s) applied to them. + + Returns a tuple ``(rule_items, pricing_rules_cache)`` where ``rule_items`` maps + a rule name to the list of items carrying it, and ``pricing_rules_cache`` caches + the resolved Pricing Rule docs (``None`` for rules that are not Price-type + Min/Max, so they are evaluated only once). + """ + rule_items = defaultdict(list) + pricing_rules_cache = {} + + for item in doc.get("items") or []: + if item.get("is_free_item") or _item_qty(item) <= 0 or not item.get("pricing_rules"): + continue + + for pr_name in get_applied_pricing_rules(item.get("pricing_rules")): + if pr_name not in pricing_rules_cache: + pr = frappe.get_cached_doc("Pricing Rule", pr_name) + if ( + pr.get("price_or_product_discount") == "Price" + and pr.get("apply_discount_on_price") in MIN_MAX_OPTIONS + ): + 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 diff --git a/pos_next/pos_next/custom/pricing_rule.json b/pos_next/pos_next/custom/pricing_rule.json index eb5d9a211..fc2fc2063 100644 --- a/pos_next/pos_next/custom/pricing_rule.json +++ b/pos_next/pos_next/custom/pricing_rule.json @@ -1,5 +1,97 @@ { "custom_fields": [ + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": null, + "depends_on": "eval:doc.price_or_product_discount == 'Price'", + "description": "Apply the discount only to the cheapest (Min) or most expensive (Max) item(s) in the document, ranked by price list rate, limited by the qty below. The whole document is evaluated together (Mixed Conditions is forced on). Leave blank for the normal behaviour of discounting every matching item.", + "docstatus": 0, + "dt": "Pricing Rule", + "fieldname": "apply_discount_on_price", + "fieldtype": "Select", + "hidden": 0, + "idx": 18, + "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", + "length": 0, + "mandatory_depends_on": null, + "module": null, + "name": "Pricing Rule-apply_discount_on_price", + "no_copy": 0, + "non_negative": 0, + "options": "\nMin\nMax", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "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, + "columns": 0, + "default": "1", + "depends_on": "eval:doc.apply_discount_on_price == 'Min' || doc.apply_discount_on_price == 'Max'", + "description": "Number of units to discount, taken from the cheapest (Min) / most expensive (Max) items first. Minimum 1 (blank/0 is treated as 1).", + "docstatus": 0, + "dt": "Pricing Rule", + "fieldname": "min_or_max_discount_qty_limit", + "fieldtype": "Int", + "hidden": 0, + "idx": 19, + "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, + "mandatory_depends_on": null, + "module": null, + "name": "Pricing Rule-min_or_max_discount_qty_limit", + "no_copy": 0, + "non_negative": 1, + "options": null, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, { "_assign": null, "_comments": null, diff --git a/pos_next/pos_next/custom/promotional_scheme_price_discount.json b/pos_next/pos_next/custom/promotional_scheme_price_discount.json new file mode 100644 index 000000000..dde76636e --- /dev/null +++ b/pos_next/pos_next/custom/promotional_scheme_price_discount.json @@ -0,0 +1,101 @@ +{ + "custom_fields": [ + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": null, + "depends_on": null, + "description": "Apply the discount only to the cheapest (Min) or most expensive (Max) item(s) in the document, ranked by price list rate, limited by the qty below. The whole document is evaluated together (Mixed Conditions is forced on). Leave blank for the normal behaviour of discounting every matching item.", + "docstatus": 0, + "dt": "Promotional Scheme Price Discount", + "fieldname": "apply_discount_on_price", + "fieldtype": "Select", + "hidden": 0, + "idx": 1, + "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", + "length": 0, + "mandatory_depends_on": null, + "module": null, + "name": "Promotional Scheme Price Discount-apply_discount_on_price", + "no_copy": 0, + "non_negative": 0, + "options": "\nMin\nMax", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "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, + "columns": 0, + "default": "1", + "depends_on": "eval:doc.apply_discount_on_price == 'Min' || doc.apply_discount_on_price == 'Max'", + "description": "Number of units to discount, taken from the cheapest (Min) / most expensive (Max) items first. Minimum 1 (blank/0 is treated as 1).", + "docstatus": 0, + "dt": "Promotional Scheme Price Discount", + "fieldname": "min_or_max_discount_qty_limit", + "fieldtype": "Int", + "hidden": 0, + "idx": 2, + "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, + "mandatory_depends_on": null, + "module": null, + "name": "Promotional Scheme Price Discount-min_or_max_discount_qty_limit", + "no_copy": 0, + "non_negative": 1, + "options": null, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Promotional Scheme Price Discount", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} diff --git a/pos_next/public/js/pricing_rule.js b/pos_next/public/js/pricing_rule.js new file mode 100644 index 000000000..67bbbb167 --- /dev/null +++ b/pos_next/public/js/pricing_rule.js @@ -0,0 +1,46 @@ +// Copyright (c) 2026, BrainWise and contributors +// For license information, please see license.txt + + +frappe.ui.form.on("Pricing Rule", { + refresh(frm) { + pn_toggle_min_max(frm); + }, + apply_discount_on_price(frm) { + pn_toggle_min_max(frm); + }, + min_or_max_discount_qty_limit(frm) { + pn_clamp_qty_limit(frm); + }, +}); + +function pn_toggle_min_max(frm) { + const is_min_max = ["Min", "Max"].includes(frm.doc.apply_discount_on_price); + if (is_min_max) { + if (!frm.doc.mixed_conditions) { + frm.set_value("mixed_conditions", 1); + } + pn_clamp_qty_limit(frm); + } + frm.set_df_property("mixed_conditions", "read_only", is_min_max ? 1 : 0); + frm.set_df_property( + "mixed_conditions", + "description", + is_min_max + ? __( + "Automatically enabled and locked because Apply Discount On is set to Min/Max. " + + "A cheapest/most-expensive-item discount must evaluate all document items together " + + "(not line by line), which requires Mixed Conditions. Clear Apply Discount On to edit this." + ) + : "" + ); +} + +function pn_clamp_qty_limit(frm) { + if ( + ["Min", "Max"].includes(frm.doc.apply_discount_on_price) && + (frm.doc.min_or_max_discount_qty_limit || 0) < 1 + ) { + frm.set_value("min_or_max_discount_qty_limit", 1); + } +} diff --git a/pos_next/public/js/promotional_scheme.js b/pos_next/public/js/promotional_scheme.js new file mode 100644 index 000000000..99b3a2285 --- /dev/null +++ b/pos_next/public/js/promotional_scheme.js @@ -0,0 +1,48 @@ +// Copyright (c) 2026, BrainWise and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Promotional Scheme", { + refresh(frm) { + pn_sync_min_max(frm); + }, + price_discount_slabs_remove(frm) { + pn_sync_min_max(frm); + }, +}); + +frappe.ui.form.on("Promotional Scheme Price Discount", { + apply_discount_on_price(frm, cdt, cdn) { + const row = locals[cdt][cdn]; + if (["Min", "Max"].includes(row.apply_discount_on_price) && (row.min_or_max_discount_qty_limit || 0) < 1) { + frappe.model.set_value(cdt, cdn, "min_or_max_discount_qty_limit", 1); + } + pn_sync_min_max(frm); + }, + min_or_max_discount_qty_limit(frm, cdt, cdn) { + const row = locals[cdt][cdn]; + if (["Min", "Max"].includes(row.apply_discount_on_price) && (row.min_or_max_discount_qty_limit || 0) < 1) { + frappe.model.set_value(cdt, cdn, "min_or_max_discount_qty_limit", 1); + } + }, +}); + +function pn_sync_min_max(frm) { + const has_min_max = (frm.doc.price_discount_slabs || []).some((row) => + ["Min", "Max"].includes(row.apply_discount_on_price) + ); + if (has_min_max && !frm.doc.mixed_conditions) { + frm.set_value("mixed_conditions", 1); + } + frm.set_df_property("mixed_conditions", "read_only", has_min_max ? 1 : 0); + frm.set_df_property( + "mixed_conditions", + "description", + has_min_max + ? __( + "Automatically enabled and locked because a price discount row uses a Min/Max discount. " + + "A cheapest/most-expensive-item discount must evaluate all document items together " + + "which requires Mixed Conditions. Remove the Min/Max rows to edit this." + ) + : "" + ); +} From 5364a7e705c3d9b10bbeb60839cbad4d856ba118 Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Tue, 30 Jun 2026 13:30:11 +0300 Subject: [PATCH 2/2] feat: refine min/max discount logic and update descriptions for clarity --- pos_next/overrides/pricing_rule.py | 59 +++++++++---------- pos_next/pos_next/custom/pricing_rule.json | 4 +- .../promotional_scheme_price_discount.json | 4 +- pos_next/public/js/pricing_rule.js | 19 +----- pos_next/public/js/promotional_scheme.js | 12 +--- 5 files changed, 35 insertions(+), 63 deletions(-) diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index c3e1c245e..928a24c05 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -13,8 +13,9 @@ Min/Max Price Discounts ----------------------- A Price-type Pricing Rule may set ``apply_discount_on_price`` to ``Min`` or -``Max`` so the discount lands only on the cheapest (Min) or most expensive -(Max) item(s) carrying that rule, capped by ``min_or_max_discount_qty_limit``. +``Max`` so the discount lands only on the single cheapest (Min) or most +expensive (Max) line carrying that rule. ``min_or_max_discount_qty_limit`` caps +how many units of that one line are discounted (``0`` = every unit of the line). ERPNext's per-item engine cannot rank items against each other, so we suppress its application of these rules (``apply_price_discount_rule`` below) and apply them in a single bulk pass (``apply_min_max_price_discounts``) instead. @@ -123,8 +124,8 @@ def enforce_min_max_pricing_config(doc, method=None): A Min/Max ("cheapest / most expensive item") discount only makes sense when the engine evaluates the whole document together, so we force ``mixed_conditions`` on (ERPNext otherwise gates each line independently and a one-of-each cart never - qualifies). We also reject a non-positive quantity limit, which would mean - "discount nothing". + qualifies). The quantity limit may be ``0`` (discount every unit of the cheapest / + most expensive line). Wired as a ``validate`` doc_event for both **Promotional Scheme** (Min/Max lives on the price-discount slabs and the generated rules inherit ``mixed_conditions``) @@ -140,11 +141,11 @@ def enforce_min_max_pricing_config(doc, method=None): return doc.mixed_conditions = 1 for slab in min_max_slabs: - if flt(slab.get("min_or_max_discount_qty_limit")) < 1: + if flt(slab.get("min_or_max_discount_qty_limit")) < 0: frappe.throw( _( - "Set Min/Max Discount Qty Limit to at least 1 on the price " - "discount row using {0} discount." + "Min/Max Discount Qty Limit cannot be negative on the price " + "discount row using {0} discount. Use 0 for no limit." ).format(slab.get("apply_discount_on_price")) ) return @@ -153,11 +154,11 @@ def enforce_min_max_pricing_config(doc, method=None): if doc.get("apply_discount_on_price") not in MIN_MAX_OPTIONS: return doc.mixed_conditions = 1 - if flt(doc.get("min_or_max_discount_qty_limit")) < 1: + if flt(doc.get("min_or_max_discount_qty_limit")) < 0: frappe.throw( _( - "Set Min/Max Discount Qty Limit to at least 1 when " - "Apply Discount On is {0}." + "Min/Max Discount Qty Limit cannot be negative when " + "Apply Discount On is {0}. Use 0 for no limit." ).format(doc.get("apply_discount_on_price")) ) @@ -203,13 +204,15 @@ def apply_min_max_price_discounts(doc, method=None, allowed_rules=None): Used both as a ``doc_events`` ``validate`` hook (real documents) and from the POS ``apply_offers`` API (lightweight mock document). For each Min/Max rule it - ranks the items carrying that rule by price and discounts only those within the - configured quantity limit; items that do not qualify are left untouched so any - discount applied by *other* rules survives. + ranks the items carrying that rule by price, picks the single cheapest (Min) / + most expensive (Max) line and discounts up to ``min_or_max_discount_qty_limit`` + units of it (``0`` = every unit of that line). It never spills onto the next + item, and every other item is left untouched so discounts applied by *other* + rules survive. - Limitation: on an item that *wins* the Min/Max ranking, the blended Min/Max + Limitation: on the line that *wins* the Min/Max ranking, the blended Min/Max percentage replaces (does not stack on top of) any discount another rule gave - that same item. Combining ``apply_multiple_pricing_rules`` with Min/Max on the + that same line. Combining ``apply_multiple_pricing_rules`` with Min/Max on the same item is therefore not supported. Args: @@ -243,23 +246,17 @@ def apply_min_max_price_discounts(doc, method=None, allowed_rules=None): reverse = pr.get("apply_discount_on_price") == "Max" items.sort(key=lambda i: flt(i.get("price_list_rate")), reverse=reverse) - # Number of units to discount among the ranked items. Empty/0 is - # meaningless for a "cheapest/most-expensive item" discount, so it - # defaults to a single unit (an unlimited limit would discount every - # unit of every matching item, i.e. an ordinary discount). + # The discount targets only the single cheapest (Min; only a negative limit is rejected.) / most expensive + # (Max) line — we never spill onto the next-ranked item. The qty limit + # caps how many units of that one line are discounted: + # limit > 0 => up to ``limit`` units (rest of the line pays full price); + # limit == 0 => every unit of that line (unlimited). + target = items[0] + target_qty = _item_qty(target) qty_limit = flt(pr.get("min_or_max_discount_qty_limit") or 0) - if qty_limit <= 0: - qty_limit = 1.0 - - remaining_qty = qty_limit - for item in items: - if remaining_qty <= 0: - break - eligible_qty = min(_item_qty(item), remaining_qty) - if eligible_qty <= 0: - continue - _apply_discount(pr, item, eligible_qty) - remaining_qty -= eligible_qty + eligible_qty = target_qty if qty_limit <= 0 else min(qty_limit, target_qty) + if eligible_qty > 0: + _apply_discount(pr, target, eligible_qty) # Real documents must recalculate: our validate hook runs *after* the # controller's calculate_taxes_and_totals(), so totals are stale until we diff --git a/pos_next/pos_next/custom/pricing_rule.json b/pos_next/pos_next/custom/pricing_rule.json index fc2fc2063..a1baae82b 100644 --- a/pos_next/pos_next/custom/pricing_rule.json +++ b/pos_next/pos_next/custom/pricing_rule.json @@ -8,7 +8,7 @@ "columns": 0, "default": null, "depends_on": "eval:doc.price_or_product_discount == 'Price'", - "description": "Apply the discount only to the cheapest (Min) or most expensive (Max) item(s) in the document, ranked by price list rate, limited by the qty below. The whole document is evaluated together (Mixed Conditions is forced on). Leave blank for the normal behaviour of discounting every matching item.", + "description": "Apply the discount only to the single cheapest (Min) or most expensive (Max) line in the document, ranked by price list rate, capped by the qty below. The whole document is evaluated together (Mixed Conditions is forced on). Leave blank for the normal behaviour of discounting every matching item.", "docstatus": 0, "dt": "Pricing Rule", "fieldname": "apply_discount_on_price", @@ -54,7 +54,7 @@ "columns": 0, "default": "1", "depends_on": "eval:doc.apply_discount_on_price == 'Min' || doc.apply_discount_on_price == 'Max'", - "description": "Number of units to discount, taken from the cheapest (Min) / most expensive (Max) items first. Minimum 1 (blank/0 is treated as 1).", + "description": "Maximum units of the single cheapest (Min) / most expensive (Max) line to discount. The discount never spills onto another item; if the line's qty is below this limit, only the units bought are discounted. Set 0 for no limit (discount every unit of that line).", "docstatus": 0, "dt": "Pricing Rule", "fieldname": "min_or_max_discount_qty_limit", diff --git a/pos_next/pos_next/custom/promotional_scheme_price_discount.json b/pos_next/pos_next/custom/promotional_scheme_price_discount.json index dde76636e..cb1dc5619 100644 --- a/pos_next/pos_next/custom/promotional_scheme_price_discount.json +++ b/pos_next/pos_next/custom/promotional_scheme_price_discount.json @@ -8,7 +8,7 @@ "columns": 0, "default": null, "depends_on": null, - "description": "Apply the discount only to the cheapest (Min) or most expensive (Max) item(s) in the document, ranked by price list rate, limited by the qty below. The whole document is evaluated together (Mixed Conditions is forced on). Leave blank for the normal behaviour of discounting every matching item.", + "description": "Apply the discount only to the single cheapest (Min) or most expensive (Max) line in the document, ranked by price list rate, capped by the qty below. The whole document is evaluated together (Mixed Conditions is forced on). Leave blank for the normal behaviour of discounting every matching item.", "docstatus": 0, "dt": "Promotional Scheme Price Discount", "fieldname": "apply_discount_on_price", @@ -54,7 +54,7 @@ "columns": 0, "default": "1", "depends_on": "eval:doc.apply_discount_on_price == 'Min' || doc.apply_discount_on_price == 'Max'", - "description": "Number of units to discount, taken from the cheapest (Min) / most expensive (Max) items first. Minimum 1 (blank/0 is treated as 1).", + "description": "Maximum units of the single cheapest (Min) / most expensive (Max) line to discount. The discount never spills onto another item; if the line's qty is below this limit, only the units bought are discounted. Set 0 for no limit (discount every unit of that line).", "docstatus": 0, "dt": "Promotional Scheme Price Discount", "fieldname": "min_or_max_discount_qty_limit", diff --git a/pos_next/public/js/pricing_rule.js b/pos_next/public/js/pricing_rule.js index 67bbbb167..45c2fab92 100644 --- a/pos_next/public/js/pricing_rule.js +++ b/pos_next/public/js/pricing_rule.js @@ -9,18 +9,12 @@ frappe.ui.form.on("Pricing Rule", { apply_discount_on_price(frm) { pn_toggle_min_max(frm); }, - min_or_max_discount_qty_limit(frm) { - pn_clamp_qty_limit(frm); - }, }); function pn_toggle_min_max(frm) { const is_min_max = ["Min", "Max"].includes(frm.doc.apply_discount_on_price); - if (is_min_max) { - if (!frm.doc.mixed_conditions) { - frm.set_value("mixed_conditions", 1); - } - pn_clamp_qty_limit(frm); + if (is_min_max && !frm.doc.mixed_conditions) { + frm.set_value("mixed_conditions", 1); } frm.set_df_property("mixed_conditions", "read_only", is_min_max ? 1 : 0); frm.set_df_property( @@ -35,12 +29,3 @@ function pn_toggle_min_max(frm) { : "" ); } - -function pn_clamp_qty_limit(frm) { - if ( - ["Min", "Max"].includes(frm.doc.apply_discount_on_price) && - (frm.doc.min_or_max_discount_qty_limit || 0) < 1 - ) { - frm.set_value("min_or_max_discount_qty_limit", 1); - } -} diff --git a/pos_next/public/js/promotional_scheme.js b/pos_next/public/js/promotional_scheme.js index 99b3a2285..fcf60b514 100644 --- a/pos_next/public/js/promotional_scheme.js +++ b/pos_next/public/js/promotional_scheme.js @@ -11,19 +11,9 @@ frappe.ui.form.on("Promotional Scheme", { }); frappe.ui.form.on("Promotional Scheme Price Discount", { - apply_discount_on_price(frm, cdt, cdn) { - const row = locals[cdt][cdn]; - if (["Min", "Max"].includes(row.apply_discount_on_price) && (row.min_or_max_discount_qty_limit || 0) < 1) { - frappe.model.set_value(cdt, cdn, "min_or_max_discount_qty_limit", 1); - } + apply_discount_on_price(frm) { pn_sync_min_max(frm); }, - min_or_max_discount_qty_limit(frm, cdt, cdn) { - const row = locals[cdt][cdn]; - if (["Min", "Max"].includes(row.apply_discount_on_price) && (row.min_or_max_discount_qty_limit || 0) < 1) { - frappe.model.set_value(cdt, cdn, "min_or_max_discount_qty_limit", 1); - } - }, }); function pn_sync_min_max(frm) {