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/fixtures/custom_field.json b/pos_next/fixtures/custom_field.json index c0a8fb93..97641bc3 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": "", + "depends_on": "eval:doc.price_or_product_discount == 'Price'", + "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_price", + "fieldtype": "Select", + "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", + "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_price", + "no_copy": 0, + "non_negative": 0, + "options": "\nMin\nMax", + "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.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 753ae2db..e95ccd23 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -1,12 +1,10 @@ from pos_next.utils import get_build_version - app_name = "pos_next" app_title = "POS Next" app_publisher = "BrainWise" 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" - # Apps # ------------------ @@ -100,7 +98,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_price", + "Pricing Rule-min_or_max_discount_qty_limit" ] ] ] @@ -188,7 +188,6 @@ # DocType Class # --------------- # Override standard doctype classes - override_doctype_class = { "Sales Invoice": "pos_next.overrides.sales_invoice.CustomSalesInvoice" } @@ -207,7 +206,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": [ @@ -219,6 +219,33 @@ }, "POS Profile": { "on_update": "pos_next.realtime_events.emit_pos_profile_updated_event" + }, + "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" + }, + "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" } } @@ -237,7 +264,6 @@ "pos_next.tasks.branding_monitor.reset_tampering_counter", ], } - # Testing # ------- @@ -245,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 new file mode 100644 index 00000000..eaddd3e1 --- /dev/null +++ b/pos_next/overrides/pricing_rule.py @@ -0,0 +1,231 @@ +""" +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 +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 +) +from frappe import _ + + +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"]: + # 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) + +def apply_min_max_price_discounts(doc, method=None, allowed_rules=None): + """ + 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: 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 + rule_items, pricing_rules = _collect_min_max_rule_items(doc) + + # 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") + 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_price_list: + 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" + items.sort( + key=lambda i: flt(i.price_list_rate or i.rate or 0), + reverse=reverse + ) + + # Track remaining quantity that can receive discount + 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) + qty = flt(item.qty) + if not base_rate or qty <= 0: + continue + + # Unlimited discount to first item only if no limit is set + if not has_qty_limit: + if item == items[0]: + _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 + 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") and callable(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") + 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. + Ensures mathematical accuracy where rate * qty = amount. + """ + base_rate = flt(item.price_list_rate) or flt(item.rate) + qty = flt(item.qty) + + if not base_rate or eligible_qty <= 0 or qty <= 0: + return + + # Calculate maximum possible discount to prevent negative rates + total_discount = 0.0 + if pr.rate_or_discount == "Rate": + total_discount = (base_rate - flt(pr.rate)) * eligible_qty + 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": + total_discount = flt(pr.discount_amount) * eligible_qty + + # 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.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): + """ + 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 = {} + + items = doc.get("items") or [] + for item in items: + # Skip free items, items with zero/negative quantity, or items without 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.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) + # 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