From a01c2d79a126a046871e4e88414e15849f63d0e8 Mon Sep 17 00:00:00 2001 From: engahmed1190 Date: Wed, 13 May 2026 12:38:20 +0300 Subject: [PATCH 1/4] fix(offers): apply transaction-level promotional schemes in POS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERPNext routes Pricing Rules with apply_on="Transaction" through a separate engine (apply_pricing_rule_on_transaction) that pos_next never invoked, so "Entire Transaction" Promotional Schemes — like "spend X get free product Y" — were silently dropped regardless of cart contents. apply_offers() now also builds a transient (never-saved) Sales Invoice and runs ERPNext's transaction-level engine on it, merging any free-item rows into the existing free_items response under the same (item_code, pricing_rule) dedup key the per-item path uses. The POS Next Receipt Jinja template now annotates is_free_item rows with a green "(FREE)" badge and renders the rate column as "qty x FREE / IQD 0.00", matching the cart and offline-receipt treatment so customers and cashiers see consistent labeling on server-rendered prints (browser + silent QZ Tray paths). Co-Authored-By: Claude Opus 4.7 (1M context) --- pos_next/api/invoices.py | 161 ++++++++++++++++++ .../pos_next_receipt/pos_next_receipt.json | 4 +- 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/pos_next/api/invoices.py b/pos_next/api/invoices.py index 0d592f8f..13887d9d 100644 --- a/pos_next/api/invoices.py +++ b/pos_next/api/invoices.py @@ -40,9 +40,13 @@ from erpnext.accounts.doctype.pricing_rule.utils import ( get_applied_pricing_rules as erpnext_get_applied_pricing_rules, ) + from erpnext.accounts.doctype.pricing_rule.utils import ( + apply_pricing_rule_on_transaction as erpnext_apply_pricing_rule_on_transaction, + ) 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 # ========================================== @@ -2677,6 +2681,114 @@ def search_invoices_for_return( # ========================================== +def _evaluate_transaction_offers( + invoice, + profile, + pricing_items, + customer, + customer_group, + territory, + posting_date, + currency, + price_list, + rule_map, + selected_offer_names, +): + """Run ERPNext's transaction-level pricing engine and collect free items. + + ERPNext routes `apply_on = "Transaction"` rules through a different entry + point (`apply_pricing_rule_on_transaction`) than the per-item engine. That + function mutates a real Sales Invoice document in place — appending free + item rows via `doc.append("items", ...)` — so we build a transient, + never-saved Sales Invoice document for evaluation only. + + Returns {"free_items": dict keyed by (item_code, rule_name), "applied_rules": set}. + """ + if not erpnext_apply_pricing_rule_on_transaction or not pricing_items: + return {"free_items": {}, "applied_rules": set()} + + total_qty = sum(flt(it.qty) for it in pricing_items) + total = sum(flt(it.qty) * flt(it.rate) for it in pricing_items) + if total <= 0: + return {"free_items": {}, "applied_rules": set()} + + doc = frappe.new_doc("Sales Invoice") + doc.update( + { + "is_pos": 1, + "company": profile.company, + "currency": currency, + "conversion_rate": 1, + "selling_price_list": price_list, + "price_list_currency": currency, + "plc_conversion_rate": 1, + "customer": customer, + "customer_group": customer_group, + "territory": territory, + "transaction_date": posting_date, + "posting_date": posting_date, + "pos_profile": invoice.get("pos_profile"), + "coupon_code": invoice.get("coupon_code") or None, + } + ) + doc.flags.ignore_mandatory = True + + for prep in pricing_items: + doc.append( + "items", + { + "item_code": prep.item_code, + "item_name": prep.item_name, + "item_group": prep.item_group, + "brand": prep.brand, + "qty": prep.qty, + "stock_qty": prep.stock_qty, + "conversion_factor": prep.conversion_factor, + "uom": prep.uom, + "stock_uom": prep.stock_uom, + "rate": prep.rate, + "price_list_rate": prep.price_list_rate, + "base_rate": prep.base_rate, + "base_price_list_rate": prep.base_price_list_rate, + "amount": flt(prep.rate) * flt(prep.qty), + "warehouse": prep.warehouse, + }, + ) + + # filter_pricing_rules_for_qty_amount reads these straight off the doc + # (erpnext/accounts/doctype/pricing_rule/utils.py:572). + doc.total_qty = total_qty + doc.total = total + + initial_item_count = len(doc.items) + try: + erpnext_apply_pricing_rule_on_transaction(doc) + except Exception: + # A misconfigured transaction-scoped rule must not break the per-item + # discounts that have already been computed by the caller. + frappe.log_error( + frappe.get_traceback(), "POS Apply Offers (Transaction Rules)" + ) + return {"free_items": {}, "applied_rules": set()} + + free_items = {} + applied_rules = set() + for row in doc.items[initial_item_count:]: + if not getattr(row, "is_free_item", 0): + continue + rule_name = row.get("pricing_rules") + if not rule_name or rule_name not in rule_map: + continue + if selected_offer_names and rule_name not in selected_offer_names: + continue + fid = frappe._dict(row.as_dict()) + fid.applied_promotional_scheme = rule_map[rule_name].promotional_scheme + free_items[(row.item_code, rule_name)] = fid + applied_rules.add(rule_name) + + return {"free_items": free_items, "applied_rules": applied_rules} + + @frappe.whitelist() def apply_offers(invoice_data, selected_offers=None): """Calculate and apply promotional offers using ERPNext Pricing Rules. @@ -2917,6 +3029,32 @@ def apply_offers(invoice_data, selected_offers=None): # Include both promotional scheme rules and standalone pricing rules rule_map[record.name] = record + # Top up rule_map with transaction-scoped rules. The per-item engine + # never surfaces apply_on="Transaction" rules, so without this they + # would be dropped at the `if not rule_map: return` check below. + # ERPNext's own SQL inside apply_pricing_rule_on_transaction handles + # date/currency/pos_only filtering, so a broad superset is sufficient. + if erpnext_apply_pricing_rule_on_transaction: + txn_rule_records = frappe.get_all( + "Pricing Rule", + filters={ + "disable": 0, + "apply_on": "Transaction", + "company": profile.company, + "selling": 1, + "coupon_code_based": 0, + }, + fields=[ + "name", + "promotional_scheme", + "coupon_code_based", + "promotional_scheme_id", + "price_or_product_discount", + ], + ) + for record in txn_rule_records: + rule_map.setdefault(record.name, record) + if selected_offer_names: # Restrict available rules to the ones explicitly selected from the UI. rule_map = { @@ -3052,6 +3190,29 @@ def apply_offers(invoice_data, selected_offers=None): ].promotional_scheme free_items_map[(free_item.get("item_code"), rule_name)] = free_item_doc + # Evaluate apply_on="Transaction" rules through ERPNext's separate + # transaction-level engine. The per-item engine above does not see + # them, so without this step "Entire Transaction" promotional schemes + # (free product based on cart total) would never apply. + txn_result = _evaluate_transaction_offers( + invoice, + profile, + pricing_items, + customer, + customer_group, + territory, + invoice.get("posting_date") or nowdate(), + pricing_args.currency, + pricing_args.price_list, + rule_map, + selected_offer_names, + ) + # Per-item results win on collisions because they already carry full + # discount metadata from the per-item engine result. + for key, free_item_doc in txn_result.get("free_items", {}).items(): + free_items_map.setdefault(key, free_item_doc) + applied_rules.update(txn_result.get("applied_rules", set())) + 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/pos_next/print_format/pos_next_receipt/pos_next_receipt.json b/pos_next/pos_next/print_format/pos_next_receipt/pos_next_receipt.json index 71330fcc..d7d90804 100644 --- a/pos_next/pos_next/print_format/pos_next_receipt/pos_next_receipt.json +++ b/pos_next/pos_next/print_format/pos_next_receipt/pos_next_receipt.json @@ -9,14 +9,14 @@ "docstatus": 0, "doctype": "Print Format", "font_size": 12, - "html": "\n\n{%- set currency_symbol = frappe.db.get_value(\"Currency\", doc.currency, \"symbol\") -%}\n{%- if not currency_symbol -%}\n\t{%- set currency_symbol = doc.currency -%}\n{%- endif -%}\n\n\n
{{ doc.company }}
\n
TAX INVOICE
\n\n\n
\n\t
Invoice: {{ doc.name }}
\n\t
Date: {{ doc.posting_date }} {{ (doc.posting_time|string).split('.')[0] if doc.posting_time else '' }}
\n\t{%- if doc.customer_name -%}\n\t
Customer: {{ doc.customer_name }}
\n\t{%- endif -%}\n\t{%- if doc.status == \"Partly Paid\" or (doc.outstanding_amount and doc.outstanding_amount > 0 and doc.outstanding_amount < doc.grand_total) -%}\n\t
Status: PARTIAL PAYMENT
\n\t{%- endif -%}\n
\n\n
\n\n\n{%- for item in doc.items -%}\n
\n\t
{{ item.item_name }}
\n\t
\n\t\t{{ \"%.0f\"|format(item.qty) }} \u00d7 {{ currency_symbol }} {{ \"%.2f\"|format(item.price_list_rate or item.rate) }}\n\t\t{{ currency_symbol }} {{ \"%.2f\"|format(item.qty * (item.price_list_rate or item.rate)) }}\n\t
\n\t{%- if item.discount_percentage or item.discount_amount -%}\n\t
\n\t\tDiscount{%- if item.discount_percentage -%} ({{ \"%.1f\"|format(item.discount_percentage) }}%){%- endif -%}\n\t\t-{{ currency_symbol }} {{ \"%.2f\"|format(item.discount_amount or 0) }}\n\t
\n\t{%- endif -%}\n\t{%- if item.serial_no -%}\n\t
\n\t\t
Serial No:
\n\t\t
{{ item.serial_no | replace(\"\\n\", \", \") }}
\n\t
\n\t{%- endif -%}\n
\n{%- endfor -%}\n\n
\n\n\n{%- if doc.total_taxes_and_charges and doc.total_taxes_and_charges > 0 -%}\n
\n\tSubtotal:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.grand_total - doc.total_taxes_and_charges) }}\n
\n
\n\tTax:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.total_taxes_and_charges) }}\n
\n{%- endif -%}\n{%- if doc.discount_amount -%}\n
\n\tAdditional Discount{%- if doc.additional_discount_percentage -%} ({{ \"%.1f\"|format(doc.additional_discount_percentage) }}%){%- endif -%}:\n\t-{{ currency_symbol }} {{ \"%.2f\"|format(doc.discount_amount|abs) }}\n
\n{%- endif -%}\n
\n\tTOTAL:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.grand_total) }}\n
\n\n\n{%- if doc.payments -%}\n
\n
Payments:
\n{%- for payment in doc.payments -%}\n
\n\t{{ payment.mode_of_payment }}:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(payment.amount) }}\n
\n{%- endfor -%}\n
\n\tTotal Paid:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.paid_amount or 0) }}\n
\n{%- if doc.change_amount > 0 -%}\n
\n\tChange:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.change_amount) }}\n
\n{%- endif -%}\n{%- if doc.outstanding_amount and doc.outstanding_amount > 0 -%}\n
\n\tBALANCE DUE:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.outstanding_amount) }}\n
\n{%- endif -%}\n{%- endif -%}\n\n\n
\n\t
Thank you for your business!
\n\t
Powered by POS Next
\n
", + "html": "\n\n{%- set currency_symbol = frappe.db.get_value(\"Currency\", doc.currency, \"symbol\") -%}\n{%- if not currency_symbol -%}\n\t{%- set currency_symbol = doc.currency -%}\n{%- endif -%}\n\n\n
{{ doc.company }}
\n
TAX INVOICE
\n\n\n
\n\t
Invoice: {{ doc.name }}
\n\t
Date: {{ doc.posting_date }} {{ (doc.posting_time|string).split('.')[0] if doc.posting_time else '' }}
\n\t{%- if doc.customer_name -%}\n\t
Customer: {{ doc.customer_name }}
\n\t{%- endif -%}\n\t{%- if doc.status == \"Partly Paid\" or (doc.outstanding_amount and doc.outstanding_amount > 0 and doc.outstanding_amount < doc.grand_total) -%}\n\t
Status: PARTIAL PAYMENT
\n\t{%- endif -%}\n
\n\n
\n\n\n{%- for item in doc.items -%}\n
\n\t
{{ item.item_name }}{%- if item.is_free_item %} ({{ _(\"FREE\") }}){%- endif -%}
\n\t
\n\t\t{%- if item.is_free_item -%}\n\t\t{{ \"%.0f\"|format(item.qty) }} \u00d7 {{ _(\"FREE\") }}\n\t\t{{ currency_symbol }} 0.00\n\t\t{%- else -%}\n\t\t{{ \"%.0f\"|format(item.qty) }} \u00d7 {{ currency_symbol }} {{ \"%.2f\"|format(item.price_list_rate or item.rate) }}\n\t\t{{ currency_symbol }} {{ \"%.2f\"|format(item.qty * (item.price_list_rate or item.rate)) }}\n\t\t{%- endif -%}\n\t
\n\t{%- if item.discount_percentage or item.discount_amount -%}\n\t
\n\t\tDiscount{%- if item.discount_percentage -%} ({{ \"%.1f\"|format(item.discount_percentage) }}%){%- endif -%}\n\t\t-{{ currency_symbol }} {{ \"%.2f\"|format(item.discount_amount or 0) }}\n\t
\n\t{%- endif -%}\n\t{%- if item.serial_no -%}\n\t
\n\t\t
Serial No:
\n\t\t
{{ item.serial_no | replace(\"\\n\", \", \") }}
\n\t
\n\t{%- endif -%}\n
\n{%- endfor -%}\n\n
\n\n\n{%- if doc.total_taxes_and_charges and doc.total_taxes_and_charges > 0 -%}\n
\n\tSubtotal:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.grand_total - doc.total_taxes_and_charges) }}\n
\n
\n\tTax:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.total_taxes_and_charges) }}\n
\n{%- endif -%}\n{%- if doc.discount_amount -%}\n
\n\tAdditional Discount{%- if doc.additional_discount_percentage -%} ({{ \"%.1f\"|format(doc.additional_discount_percentage) }}%){%- endif -%}:\n\t-{{ currency_symbol }} {{ \"%.2f\"|format(doc.discount_amount|abs) }}\n
\n{%- endif -%}\n
\n\tTOTAL:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.grand_total) }}\n
\n\n\n{%- if doc.payments -%}\n
\n
Payments:
\n{%- for payment in doc.payments -%}\n
\n\t{{ payment.mode_of_payment }}:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(payment.amount) }}\n
\n{%- endfor -%}\n
\n\tTotal Paid:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.paid_amount or 0) }}\n
\n{%- if doc.change_amount > 0 -%}\n
\n\tChange:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.change_amount) }}\n
\n{%- endif -%}\n{%- if doc.outstanding_amount and doc.outstanding_amount > 0 -%}\n
\n\tBALANCE DUE:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.outstanding_amount) }}\n
\n{%- endif -%}\n{%- endif -%}\n\n\n
\n\t
Thank you for your business!
\n\t
Powered by POS Next
\n
", "idx": 0, "line_breaks": 0, "margin_bottom": 0.0, "margin_left": 0.0, "margin_right": 0.0, "margin_top": 0.0, - "modified": "2026-02-26 15:22:47.699244", + "modified": "2026-05-13 12:30:00.000000", "modified_by": "Administrator", "module": "POS Next", "name": "POS Next Receipt", From ba79473411c56f467148ac4c9899beac7077ba0b Mon Sep 17 00:00:00 2001 From: MohamedAliSmk Date: Wed, 13 May 2026 15:22:35 +0300 Subject: [PATCH 2/4] feat(invoice): enhance free item handling in invoice submission Updated the `formatItemsForSubmission` function to synthesize dedicated free item rows when legacy carts indicate free quantities via `free_qty`. This ensures compatibility with ERPNext's Sales Invoice expectations, which require separate rows for free items. Additionally, modified the `usePOSCartStore` to consistently handle free items by adding dedicated rows, improving the overall handling of promotional offers in both online and offline modes. Refactored the sales invoice logic to merge packed items from free bundles into their corresponding paid rows, preventing duplication and ensuring accurate stock management. --- POS/src/composables/useInvoice.js | 44 +++++++++++- POS/src/stores/posCart.js | 104 ++++++++++++++++++---------- pos_next/overrides/sales_invoice.py | 84 ++++++++++++++++++++++ 3 files changed, 194 insertions(+), 38 deletions(-) diff --git a/POS/src/composables/useInvoice.js b/POS/src/composables/useInvoice.js index 234ef799..27032a72 100644 --- a/POS/src/composables/useInvoice.js +++ b/POS/src/composables/useInvoice.js @@ -739,11 +739,15 @@ export function useInvoice() { * Format cart items for server submission. * Used by both online and offline flows for consistent formatting. * + * Legacy carts could mark same-item BOGO only via `free_qty` on the paid line. + * Sales Invoice Item has no `free_qty` field — ERPNext expects a second row with + * is_free_item=1. When there is no matching dedicated free row, synthesize one. + * * @param {Array} items - Raw cart items * @returns {Array} Items formatted for ERPNext Sales Invoice */ function formatItemsForSubmission(items) { - return items.map((item) => ({ + const mapRow = (item) => ({ item_code: item.item_code, item_name: item.item_name, qty: item.quantity || item.qty || 1, @@ -761,7 +765,43 @@ export function useInvoice() { is_rate_manually_edited: item.is_rate_manually_edited || 0, original_rate: item.original_rate || null, is_free_item: item.is_free_item || 0, - })) + }) + + const out = [] + for (const item of items) { + out.push(mapRow(item)) + const fq = Number.parseFloat(item.free_qty) || 0 + if (!item.is_free_item && fq > 0) { + const u = item.uom || item.stock_uom + const hasDedicatedFree = items.some( + (i) => + i.is_free_item && + i.item_code === item.item_code && + (i.uom || i.stock_uom) === u, + ) + if (!hasDedicatedFree) { + out.push({ + item_code: item.item_code, + item_name: item.item_name, + qty: fq, + rate: 0, + price_list_rate: 0, + uom: item.uom, + warehouse: item.warehouse, + batch_no: item.batch_no, + serial_no: item.serial_no, + conversion_factor: item.conversion_factor || 1, + discount_percentage: 0, + discount_amount: 0, + pricing_rules: stringifyPricingRules(item.pricing_rules), + is_rate_manually_edited: 0, + original_rate: null, + is_free_item: 1, + }) + } + } + } + return out } function addPayment(payment) { diff --git a/POS/src/stores/posCart.js b/POS/src/stores/posCart.js index f39dd3a1..bb1710d3 100644 --- a/POS/src/stores/posCart.js +++ b/POS/src/stores/posCart.js @@ -380,10 +380,11 @@ export const usePOSCartStore = defineStore("posCart", () => { /** * Processes free items from backend offer response. * - * Two cases: - * 1. Same item (free item matches an existing cart item) → sets free_qty on that item - * 2. Different product (free item not in cart) → adds a dedicated free item row - * with is_free_item=1, rate=0, non-editable in UI + * ERPNext represents product discounts as separate SI rows: paid line(s) plus + * one or more rows with is_free_item=1 (see pricing_rule tests for same_item). + * We always add a dedicated free row so formatItemsForSubmission sends qty > 0 + * for stock and accounting; annotating only free_qty on the paid line never + * reaches Sales Invoice Item (there is no free_qty field server-side). * * @param {Array} freeItems - Array of free items from backend (e.g., [{item_code, qty, uom, item_name}]) * @returns {void} @@ -418,30 +419,26 @@ export const usePOSCartStore = defineStore("posCart", () => { (item.uom || item.stock_uom) === freeUom ) - if (cartItem) { - // Same item is already in cart — just annotate with free_qty - cartItem.free_qty = freeQty - } else { - // Different product — add a dedicated free item row - invoiceItems.value.push({ - item_code: freeItem.item_code, - item_name: freeItem.item_name || freeItem.item_code, - rate: 0, - price_list_rate: 0, - quantity: freeQty, - discount_amount: 0, - discount_percentage: 0, - tax_amount: 0, - amount: 0, - stock_qty: 0, - uom: freeUom, - stock_uom: freeItem.stock_uom || freeUom, - conversion_factor: freeItem.conversion_factor || 1, - is_free_item: 1, - free_qty: freeQty, - pricing_rules: freeItem.pricing_rules || null, - }) - } + const cf = freeItem.conversion_factor || cartItem?.conversion_factor || 1 + invoiceItems.value.push({ + item_code: freeItem.item_code, + item_name: freeItem.item_name || cartItem?.item_name || freeItem.item_code, + rate: 0, + price_list_rate: 0, + quantity: freeQty, + discount_amount: 0, + discount_percentage: 0, + tax_amount: 0, + amount: 0, + stock_qty: 0, + uom: freeUom, + stock_uom: freeItem.stock_uom || freeUom, + conversion_factor: cf, + is_free_item: 1, + free_qty: freeQty, + pricing_rules: freeItem.pricing_rules || null, + warehouse: freeItem.warehouse || cartItem?.warehouse, + }) } rebuildIncrementalCache() @@ -822,7 +819,7 @@ export const usePOSCartStore = defineStore("posCart", () => { * In offline mode, we: * 1. Check eligibility using posOffers.checkOfferEligibility * 2. Apply discount percentage/amount directly to cart items - * 3. Handle free items (product discounts) by setting free_qty + * 3. Handle free items (product discounts) via dedicated is_free_item rows (same as online) * 4. Mark offers as applied (with source: "offline") * * Supports: @@ -1022,14 +1019,49 @@ export const usePOSCartStore = defineStore("posCart", () => { } } - if (freeItemsToGive > 0 && (!item.free_qty || item.free_qty === 0)) { - item.free_qty = freeItemsToGive - item.pricing_rules = item.pricing_rules || [] - if (!item.pricing_rules.includes(offer.name)) { - item.pricing_rules.push(offer.name) - } - applied = true + if (freeItemsToGive <= 0) { + continue } + const uomKey = item.uom || item.stock_uom + const existingFreeRow = invoiceItems.value.find( + (r) => + r.is_free_item && + r.item_code === item.item_code && + (r.uom || r.stock_uom) === uomKey, + ) + if (existingFreeRow) { + existingFreeRow.quantity = freeItemsToGive + existingFreeRow.free_qty = freeItemsToGive + const pr = existingFreeRow.pricing_rules + const prArr = Array.isArray(pr) + ? [...pr] + : pr + ? String(pr).split(',').map((s) => s.trim()).filter(Boolean) + : [] + if (!prArr.includes(offer.name)) prArr.push(offer.name) + existingFreeRow.pricing_rules = prArr + } else { + invoiceItems.value.push({ + item_code: item.item_code, + item_name: item.item_name || item.item_code, + rate: 0, + price_list_rate: 0, + quantity: freeItemsToGive, + discount_amount: 0, + discount_percentage: 0, + tax_amount: 0, + amount: 0, + stock_qty: 0, + uom: uomKey, + stock_uom: item.stock_uom || uomKey, + conversion_factor: item.conversion_factor || 1, + is_free_item: 1, + free_qty: freeItemsToGive, + pricing_rules: [offer.name], + warehouse: item.warehouse, + }) + } + applied = true } } else if (freeItemCode) { // Free item is a specific different item diff --git a/pos_next/overrides/sales_invoice.py b/pos_next/overrides/sales_invoice.py index 069d5047..027cc5b9 100644 --- a/pos_next/overrides/sales_invoice.py +++ b/pos_next/overrides/sales_invoice.py @@ -12,6 +12,47 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice from erpnext.accounts.utils import get_account_currency + +def _find_paid_bundle_row_for_free(si_doc, free_row): + """Pick the paid SI item row whose bundle qty should absorb this free bundle's packed items.""" + candidates = [] + for row in si_doc.get("items"): + if row.name == free_row.name or cint(row.is_free_item): + continue + if row.item_code != free_row.item_code: + continue + if (row.warehouse or "") != (free_row.warehouse or ""): + continue + candidates.append(row) + if not candidates: + return None + free_idx = free_row.idx or 0 + before = [r for r in candidates if (r.idx or 0) < free_idx] + if before: + return max(before, key=lambda r: r.idx or 0) + return candidates[0] + + +def _find_matching_packed_item_for_merge(si_doc, paid_row, component_item_code, warehouse): + """Match a packed item on the paid bundle line; prefer same warehouse.""" + w = warehouse or "" + matches = [] + for pi in si_doc.get("packed_items"): + if pi.parent_detail_docname != paid_row.name: + continue + if pi.parent_item != paid_row.item_code: + continue + if pi.item_code != component_item_code: + continue + matches.append(pi) + if not matches: + return None + for pi in matches: + if (pi.warehouse or "") == w: + return pi + return matches[0] + + def _get_post_change_gl_entries_setting(): """ Get post_change_gl_entries setting compatible with ERPNext v15 and v16. @@ -44,6 +85,7 @@ def _get_post_change_gl_entries_setting(): ) return cint(result[0][0]) if result else 0 + class CustomSalesInvoice(SalesInvoice): """ Custom Sales Invoice class that handles wallet payments correctly. @@ -161,3 +203,45 @@ def get_party_and_party_type_for_pos_gl_entry(self, mode_of_payment, account): party_type, party = "Customer", self.customer return party_type, party + + def update_packing_list(self): + super().update_packing_list() + self._combine_packed_qty_for_free_product_bundles() + + def _combine_packed_qty_for_free_product_bundles(self): + """ + Merge packed_items from free bundle lines into the matching paid bundle line. + + ERPNext builds packed rows per Sales Invoice Item row. For BOGO / pricing-rule + free rows, the same product bundle often appears twice (paid + is_free_item). + That duplicates component rows. Stock and picking should follow total bundle + qty on one set of packed lines tied to the paid row. + """ + if self.is_return or not self.get("packed_items"): + return + + free_bundle_rows = [ + row + for row in self.get("items") + if row.item_code and cint(row.is_free_item) and self.has_product_bundle(row.item_code) + ] + if not free_bundle_rows: + return + + for free_row in free_bundle_rows: + paid_row = _find_paid_bundle_row_for_free(self, free_row) + if not paid_row: + continue + + to_remove = [] + for pi in list(self.get("packed_items")): + if pi.parent_detail_docname != free_row.name or pi.parent_item != free_row.item_code: + continue + tgt = _find_matching_packed_item_for_merge(self, paid_row, pi.item_code, pi.warehouse) + if tgt: + prec = tgt.precision("qty") + tgt.qty = flt(flt(tgt.qty) + flt(pi.qty), prec) + to_remove.append(pi) + + for pi in to_remove: + self.remove(pi) From f00ad13ae5f4f802a057e3b176db24407625f0c9 Mon Sep 17 00:00:00 2001 From: engahmed1190 Date: Wed, 13 May 2026 18:10:01 +0300 Subject: [PATCH 3/4] fix(invoice): set use_serial_batch_fields for batch/serial-tracked rows on submit When a batch-tracked paid item is submitted with batch_no but use_serial_batch_fields=0 and no pre-created serial_and_batch_bundle, ERPNext's auto-SBB path triggers SBB validation inside the SLE on_submit and fails for packed_items of free product bundles with "The serial and batch bundle X not linked to Sales Invoice Y". Setting use_serial_batch_fields=1 whenever batch_no or serial_no is set routes the row through ERPNext's legacy batch fields path and avoids the broken auto-SBB validation for bundle components. Co-Authored-By: Claude Opus 4.7 (1M context) --- POS/src/composables/useInvoice.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/POS/src/composables/useInvoice.js b/POS/src/composables/useInvoice.js index 27032a72..87d26f30 100644 --- a/POS/src/composables/useInvoice.js +++ b/POS/src/composables/useInvoice.js @@ -757,6 +757,7 @@ export function useInvoice() { warehouse: item.warehouse, batch_no: item.batch_no, serial_no: item.serial_no, + use_serial_batch_fields: item.batch_no || item.serial_no ? 1 : 0, conversion_factor: item.conversion_factor || 1, discount_percentage: roundCurrency(item.discount_percentage || 0), discount_amount: roundCurrency(item.discount_amount || 0), @@ -790,6 +791,7 @@ export function useInvoice() { warehouse: item.warehouse, batch_no: item.batch_no, serial_no: item.serial_no, + use_serial_batch_fields: item.batch_no || item.serial_no ? 1 : 0, conversion_factor: item.conversion_factor || 1, discount_percentage: 0, discount_amount: 0, From 43c9d5a8fdb88a78dd643d286ede4f7fbf1402d0 Mon Sep 17 00:00:00 2001 From: engahmed1190 Date: Wed, 13 May 2026 18:57:58 +0300 Subject: [PATCH 4/4] fix(invoice): set use_serial_batch_fields on batch/serial-tracked packed_items The FE-only fix for SI Item rows is not sufficient when the bundle's component items themselves are batch- or serial-tracked. ERPNext still attempts auto-SBB creation for those packed_items at SLE.on_submit time and the resulting bundle fails validate_voucher_detail_no, throwing "The serial and batch bundle X not linked to Sales Invoice Y". Override update_packing_list to mark batch/serial-tracked packed_items with use_serial_batch_fields=1 so the row goes through ERPNext's legacy batch fields path and skips the broken auto-bundle. Co-Authored-By: Claude Opus 4.7 (1M context) --- pos_next/overrides/sales_invoice.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/pos_next/overrides/sales_invoice.py b/pos_next/overrides/sales_invoice.py index 027cc5b9..6c017795 100644 --- a/pos_next/overrides/sales_invoice.py +++ b/pos_next/overrides/sales_invoice.py @@ -207,6 +207,33 @@ def get_party_and_party_type_for_pos_gl_entry(self, mode_of_payment, account): def update_packing_list(self): super().update_packing_list() self._combine_packed_qty_for_free_product_bundles() + self._set_use_serial_batch_fields_on_packed_items() + + def _set_use_serial_batch_fields_on_packed_items(self): + """ + Force packed_items for batch/serial-tracked Items to use legacy fields path. + + ERPNext's auto-SBB creation during SLE.on_submit fails to link the bundle + because SBB.voucher_detail_no gets remapped to the parent SI Item row name + (set_serial_and_batch_values) while validation expects either a matching SLE + or a Packed Item with that name. Routing through use_serial_batch_fields=1 + bypasses the broken auto-creation for the row. + """ + if not self.get("packed_items"): + return + for pi in self.get("packed_items"): + if pi.get("serial_and_batch_bundle"): + continue + tracking = frappe.get_cached_value( + "Item", + pi.item_code, + ["has_batch_no", "has_serial_no"], + as_dict=True, + ) + if not tracking: + continue + if tracking.has_batch_no or tracking.has_serial_no: + pi.use_serial_batch_fields = 1 def _combine_packed_qty_for_free_product_bundles(self): """