diff --git a/POS/src/stores/posCart.js b/POS/src/stores/posCart.js index 6b221a7a..8d451c94 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 210018ff..b11eb316 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 3376ded4..9130b6a1 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 1b6b3491..f927a6eb 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 9c83099b..70b48bf2 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 205d0887..534680d7 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 6ed6901f..928a24c0 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -9,9 +9,31 @@ (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 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. """ +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 +111,268 @@ 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). 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``) + 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")) < 0: + frappe.throw( + _( + "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 + + # 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")) < 0: + frappe.throw( + _( + "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")) + ) + + +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, 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 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 line. 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) + + # 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) + 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 + # 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 eb5d9a21..a1baae82 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 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", + "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": "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", + "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 00000000..cb1dc561 --- /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 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", + "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": "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", + "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 00000000..45c2fab9 --- /dev/null +++ b/pos_next/public/js/pricing_rule.js @@ -0,0 +1,31 @@ +// 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); + }, +}); + +function pn_toggle_min_max(frm) { + const is_min_max = ["Min", "Max"].includes(frm.doc.apply_discount_on_price); + 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( + "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." + ) + : "" + ); +} diff --git a/pos_next/public/js/promotional_scheme.js b/pos_next/public/js/promotional_scheme.js new file mode 100644 index 00000000..fcf60b51 --- /dev/null +++ b/pos_next/public/js/promotional_scheme.js @@ -0,0 +1,38 @@ +// 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) { + pn_sync_min_max(frm); + }, +}); + +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." + ) + : "" + ); +}