Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 101 additions & 12 deletions POS/src/stores/posCart.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
28 changes: 26 additions & 2 deletions pos_next/api/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down
114 changes: 114 additions & 0 deletions pos_next/fixtures/custom_field.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
46 changes: 39 additions & 7 deletions pos_next/hooks.py
Original file line number Diff line number Diff line change
@@ -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
# ------------------

Expand Down Expand Up @@ -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"
]
]
]
Expand Down Expand Up @@ -188,7 +188,6 @@
# DocType Class
# ---------------
# Override standard doctype classes

override_doctype_class = {
"Sales Invoice": "pos_next.overrides.sales_invoice.CustomSalesInvoice"
}
Expand All @@ -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": [
Expand All @@ -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"
}
}

Expand All @@ -237,15 +264,20 @@
"pos_next.tasks.branding_monitor.reset_tampering_counter",
],
}

# Testing
# -------

# before_tests = "pos_next.install.before_tests"

# 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"
# }
Expand Down
Loading
Loading