diff --git a/pos_next/__init__.py b/pos_next/__init__.py index 855eca29..478abc0c 100644 --- a/pos_next/__init__.py +++ b/pos_next/__init__.py @@ -16,9 +16,6 @@ def console(*data): # Patch get_other_conditions to exclude pos_only pricing rules from non-POS documents. -# No Frappe hook exists for non-whitelisted module-level functions (override_whitelisted_methods -# only works for @frappe.whitelist() HTTP endpoints, override_doctype_class only for DocType -# classes). This is the standard Python module init approach — runs once at import. try: from erpnext.accounts.doctype.pricing_rule import utils as pr_utils from pos_next.overrides.pricing_rule import patch_get_other_conditions diff --git a/pos_next/api/invoices.py b/pos_next/api/invoices.py index 77320ff4..bdd13ffe 100644 --- a/pos_next/api/invoices.py +++ b/pos_next/api/invoices.py @@ -2745,6 +2745,7 @@ def apply_offers(invoice_data, selected_offers=None): "doctype": invoice.get("doctype") or "Sales Invoice", "name": invoice.get("name") or "POS-INVOICE", "is_pos": 1, + "pos_profile": profile.name, "company": profile.company, "transaction_date": invoice.get("posting_date") or nowdate(), "posting_date": invoice.get("posting_date") or nowdate(), diff --git a/pos_next/api/promotions.py b/pos_next/api/promotions.py index c160cfe1..2ba7fff5 100644 --- a/pos_next/api/promotions.py +++ b/pos_next/api/promotions.py @@ -60,6 +60,27 @@ def get_promotions(pos_profile=None, company=None, include_disabled=False): ], order_by="modified desc" ) + scheme_names = [s["name"] for s in schemes] + + rows = frappe.get_all( + "Promotion Scheme POS Profile", + filters={"parent": ["in", scheme_names]}, + fields=["parent", "pos_profile"] + ) + + scheme_pos_profiles = {} + for row in rows: + scheme_pos_profiles.setdefault(row.parent, []).append(row.pos_profile) + + filtered_schemes = [] + for scheme in schemes: + pos_profiles = scheme_pos_profiles.get(scheme["name"], []) + scheme["pos_profiles"] = pos_profiles + + if not pos_profiles or (pos_profile and pos_profile in pos_profiles): + filtered_schemes.append(scheme) + + schemes = filtered_schemes # Enrich with pricing rules count and details today = getdate(nowdate()) diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index a90d71b5..80234fc0 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -57,7 +57,7 @@ def sync_pos_only_to_pricing_rules(doc, method=None): def patch_get_other_conditions(pr_utils): - """Monkey-patch get_other_conditions to filter pos_only pricing rules. + """Monkey-patch get_other_conditions to filter pricing rules. No Frappe hook exists for non-whitelisted module-level functions, so monkey-patching is the only option for this SQL condition injection. @@ -67,6 +67,8 @@ def patch_get_other_conditions(pr_utils): def _patched_get_other_conditions(conditions, values, args): conditions = _original_get_other_conditions(conditions, values, args) + conditions = _add_pos_only_condition(conditions, values, args) + conditions = _add_pos_profile_condition(conditions, values, args) if not _has_pos_only_column(): return conditions @@ -85,3 +87,94 @@ def _patched_get_other_conditions(conditions, values, args): return conditions pr_utils.get_other_conditions = _patched_get_other_conditions + +def _add_pos_only_condition(conditions, values, args): + """Filter pos_only rules for non-POS documents.""" + doctype = args.get("doctype", "") + + # POS Invoice doctype — always POS, all rules apply + if doctype in ("POS Invoice", "POS Invoice Item"): + pass + # Sales Invoice — check is_pos flag + elif doctype in ("Sales Invoice", "Sales Invoice Item"): + if not args.get("is_pos"): + conditions += " and ifnull(`tabPricing Rule`.pos_only, 0) = 0" + # All other doctypes (Quotation, SO, DN, Purchase docs) — exclude POS-only + else: + conditions += " and ifnull(`tabPricing Rule`.pos_only, 0) = 0" + + return conditions + + +def _add_pos_profile_condition(conditions, values, args): + """Filter rules based on Promotion Scheme POS Profile restrictions. + + Optimized to avoid correlated subqueries by pre-filtering promotional schemes + and using IN clauses. Uses request-level caching to avoid re-querying for + each item in the same request. + """ + pos_profile = args.get("pos_profile") + if not pos_profile: + return conditions + + # Cache key for request-level caching + cache_key = f"_pos_profile_schemes_{pos_profile}" + + # Check if we've already computed this for this request + if not hasattr(frappe.local, cache_key): + # Pre-query: Get all promotional schemes that have POS Profile restrictions + # (schemes with any rows in the child table) + restricted_schemes = frappe.db.sql_list(""" + SELECT DISTINCT parent + FROM `tabPromotion Scheme POS Profile` + """) + + # Pre-query: Get all promotional schemes that allow this specific pos_profile + allowed_schemes = frappe.db.sql_list(""" + SELECT DISTINCT parent + FROM `tabPromotion Scheme POS Profile` + WHERE pos_profile = %s + """, (pos_profile,)) + + # Store in request cache + frappe.local[cache_key] = { + "restricted": set(restricted_schemes) if restricted_schemes else set(), + "allowed": set(allowed_schemes) if allowed_schemes else set() + } + + scheme_data = frappe.local[cache_key] + restricted_schemes = scheme_data["restricted"] + allowed_schemes = scheme_data["allowed"] + + # Build condition using IN clauses instead of correlated subqueries + # Include rule if: + # 1. Rule has no promotional_scheme (standalone rule), OR + # 2. Parent scheme has NO rows in Promotion Scheme POS Profile (apply to all), OR + # 3. Current pos_profile IS in the scheme's POS Profile list + if not restricted_schemes: + # No schemes have restrictions, so all promotional schemes apply + # No additional filtering needed beyond NULL/empty check (which is already handled) + # This means all rules are included, so we don't need to add any condition + pass + else: + # Build IN clause for allowed schemes + if allowed_schemes: + allowed_list = list(allowed_schemes) + conditions += """ AND ( + `tabPricing Rule`.promotional_scheme IS NULL + OR `tabPricing Rule`.promotional_scheme = '' + OR `tabPricing Rule`.promotional_scheme NOT IN %(restricted_schemes)s + OR `tabPricing Rule`.promotional_scheme IN %(allowed_schemes)s + )""" + values["restricted_schemes"] = list(restricted_schemes) + values["allowed_schemes"] = allowed_list + else: + # No schemes allow this pos_profile, so exclude all restricted schemes + conditions += """ AND ( + `tabPricing Rule`.promotional_scheme IS NULL + OR `tabPricing Rule`.promotional_scheme = '' + OR `tabPricing Rule`.promotional_scheme NOT IN %(restricted_schemes)s + )""" + values["restricted_schemes"] = list(restricted_schemes) + + return conditions diff --git a/pos_next/pos_next/custom/promotional_scheme.json b/pos_next/pos_next/custom/promotional_scheme.json index 038b9751..8f7e2af5 100644 --- a/pos_next/pos_next/custom/promotional_scheme.json +++ b/pos_next/pos_next/custom/promotional_scheme.json @@ -11,8 +11,72 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-03-02 13:51:19.810524", + "creation": "2026-03-11 14:47:47.061432", "default": null, + "depends_on": "", + "description": "Select the POS Profiles where this Promotion Scheme should apply; leave it blank to apply to all POS Profiles.", + "docstatus": 0, + "dt": "Promotional Scheme", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "pos_profiles", + "fieldtype": "Table", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 8, + "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": "brands", + "is_system_generated": 0, + "is_virtual": 0, + "label": "POS Profiles", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-03-11 14:50:13.710615", + "modified_by": "Administrator", + "module": null, + "name": "Promotional Scheme-custom_pos_profiles", + "no_copy": 0, + "non_negative": 0, + "options": "Promotion Scheme POS Profile", + "owner": "Administrator", + "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 + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-03-11 12:27:18.773235", + "default": "0", "depends_on": "eval:doc.selling", "description": "If checked, this scheme will only apply to POS transactions (Sales Invoices with is_pos=1 and POS Invoices)", "docstatus": 0, @@ -25,24 +89,24 @@ "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 3, + "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": "disable", + "insert_after": "selling", "is_system_generated": 0, "is_virtual": 0, "label": "POS Only", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-03-02 13:54:14.946616", + "modified": "2026-03-02 12:00:00", "modified_by": "Administrator", - "module": null, - "name": "Promotional Scheme-custom_pos_only", + "module": "POS Next", + "name": "Promotional Scheme-pos_only", "no_copy": 0, "non_negative": 0, "options": null, diff --git a/pos_next/pos_next/doctype/promotion_scheme_pos_profile/__init__.py b/pos_next/pos_next/doctype/promotion_scheme_pos_profile/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pos_next/pos_next/doctype/promotion_scheme_pos_profile/promotion_scheme_pos_profile.json b/pos_next/pos_next/doctype/promotion_scheme_pos_profile/promotion_scheme_pos_profile.json new file mode 100644 index 00000000..38ae053e --- /dev/null +++ b/pos_next/pos_next/doctype/promotion_scheme_pos_profile/promotion_scheme_pos_profile.json @@ -0,0 +1,36 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-02-25 13:44:49.802466", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "pos_profile" + ], + "fields": [ + { + "fieldname": "pos_profile", + "fieldtype": "Link", + "in_list_view": 1, + "label": "POS Profile", + "options": "POS Profile", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-25 14:34:02.956883", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Promotion Scheme POS Profile", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/pos_next/pos_next/doctype/promotion_scheme_pos_profile/promotion_scheme_pos_profile.py b/pos_next/pos_next/doctype/promotion_scheme_pos_profile/promotion_scheme_pos_profile.py new file mode 100644 index 00000000..8c9c0e34 --- /dev/null +++ b/pos_next/pos_next/doctype/promotion_scheme_pos_profile/promotion_scheme_pos_profile.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, BrainWise and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class PromotionSchemePOSProfile(Document): + pass