From 46a49a134d0ec89138702cd3df7b0bf412046ca4 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 1 Dec 2025 19:23:35 +0530 Subject: [PATCH 1/4] fix(pos): add negative stock validation for product bundle (cherry picked from commit 38b453630044ec4151da5aef3dcbae944e2c9013) --- .../doctype/pos_invoice/pos_invoice.py | 101 +++++++++++++----- 1 file changed, 77 insertions(+), 24 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 80d75bfddc3..da6cb1acc4d 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -18,6 +18,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( from erpnext.accounts.party import get_due_date, get_party_account from erpnext.controllers.queries import item_query as _item_query from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +from erpnext.stock.stock_ledger import is_negative_stock_allowed class PartialPaymentValidationError(frappe.ValidationError): @@ -350,32 +351,66 @@ class POSInvoice(SalesInvoice): for d in self.get("items"): if not d.serial_and_batch_bundle: - available_stock, is_stock_item, is_negative_stock_allowed = get_stock_availability( - d.item_code, d.warehouse - ) + if frappe.db.exists("Product Bundle", d.item_code): + ( + availability, + is_stock_item, + is_negative_stock_allowed, + ) = get_product_bundle_stock_availability(d.item_code, d.warehouse, d.stock_qty) + + else: + availability, is_stock_item, is_negative_stock_allowed = get_stock_availability( + d.item_code, d.warehouse + ) if is_negative_stock_allowed: continue - item_code, warehouse, _qty = ( - frappe.bold(d.item_code), - frappe.bold(d.warehouse), - frappe.bold(d.qty), - ) - if is_stock_item and flt(available_stock) <= 0: - frappe.throw( - _("Row #{}: Item Code: {} is not available under warehouse {}.").format( - d.idx, item_code, warehouse - ), - title=_("Item Unavailable"), - ) - elif is_stock_item and flt(available_stock) < flt(d.stock_qty): - frappe.throw( - _("Row #{}: Stock quantity not enough for Item Code: {} under warehouse {}.").format( - d.idx, item_code, warehouse - ), - title=_("Item Unavailable"), - ) + if isinstance(availability, list): + error_msgs = [] + for item in availability: + if flt(item["available"]) < flt(item["required"]): + error_msgs.append( + _("
  • Packed Item {0}: Required {1}, Available {2}
  • ").format( + frappe.bold(item["item_code"]), + frappe.bold(flt(item["required"], 2)), + frappe.bold(flt(item["available"], 2)), + ) + ) + + if error_msgs: + frappe.throw( + _( + "Row #{0}: Bundle {1} in warehouse {2} has insufficient packed items:
    " + ).format( + d.idx, + frappe.bold(d.item_code), + frappe.bold(d.warehouse), + "
    ".join(error_msgs), + ), + title=_("Insufficient Stock for Product Bundle Items"), + ) + + else: + item_code, warehouse = frappe.bold(d.item_code), frappe.bold(d.warehouse) + if is_stock_item and flt(availability) <= 0: + frappe.throw( + _("Row #{0}: Item {1} has no stock in warehouse {2}.").format( + d.idx, item_code, warehouse + ), + title=_("Item Out of Stock"), + ) + elif is_stock_item and flt(availability) < flt(d.stock_qty): + frappe.throw( + _("Row #{0}: Item {1} in warehouse {2}: Available {3}, Needed {4}.").format( + d.idx, + item_code, + warehouse, + frappe.bold(flt(availability, 2)), + frappe.bold(flt(d.stock_qty, 2)), + ), + title=_("Insufficient Stock"), + ) def validate_serialised_or_batched_item(self): error_msg = [] @@ -763,8 +798,6 @@ class POSInvoice(SalesInvoice): @frappe.whitelist() def get_stock_availability(item_code, warehouse): - from erpnext.stock.stock_ledger import is_negative_stock_allowed - if frappe.db.get_value("Item", item_code, "is_stock_item"): is_stock_item = True bin_qty = get_bin_qty(item_code, warehouse) @@ -781,6 +814,26 @@ def get_stock_availability(item_code, warehouse): return 0, is_stock_item, False +def get_product_bundle_stock_availability(item_code, warehouse, item_qty): + is_stock_item = True + bundle = frappe.get_doc("Product Bundle", item_code) + availabilities = [] + for bundle_item in bundle.items: + if frappe.get_value("Item", bundle_item.item_code, "is_stock_item"): + bin_qty = get_bin_qty(bundle_item.item_code, warehouse) + reserved_qty = get_pos_reserved_qty(bundle_item.item_code, warehouse) + available = bin_qty - reserved_qty + availabilities.append( + { + "item_code": bundle_item.item_code, + "required": bundle_item.qty * item_qty, + "available": available, + } + ) + + return availabilities, is_stock_item, is_negative_stock_allowed(item_code=item_code) + + def get_bundle_availability(bundle_item_code, warehouse): product_bundle = frappe.get_doc("Product Bundle", bundle_item_code) From e5457f8bb7f7a850ff70098c69a0614c15b33b58 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 1 Dec 2025 19:57:44 +0530 Subject: [PATCH 2/4] test(pos): add test for product bundle negative stock validation (cherry picked from commit 26121524560057423b344dc54f453e1191e4911a) # Conflicts: # erpnext/accounts/doctype/pos_invoice/pos_invoice.py --- .../doctype/pos_invoice/pos_invoice.py | 5 ++ .../doctype/pos_invoice/test_pos_invoice.py | 78 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index da6cb1acc4d..ffa441aa74f 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -21,7 +21,11 @@ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.stock_ledger import is_negative_stock_allowed +<<<<<<< HEAD class PartialPaymentValidationError(frappe.ValidationError): +======= +class ProductBundleStockValidationError(frappe.ValidationError): +>>>>>>> 2612152456 (test(pos): add test for product bundle negative stock validation) pass @@ -389,6 +393,7 @@ class POSInvoice(SalesInvoice): "
    ".join(error_msgs), ), title=_("Insufficient Stock for Product Bundle Items"), + exc=ProductBundleStockValidationError, ) else: diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index 2cc1d5f22bb..b0c16ac27d1 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -964,6 +964,84 @@ class TestPOSInvoice(unittest.TestCase): frappe.db.rollback(save_point="before_test_delivered_serial_no_case") frappe.set_user("Administrator") + def test_bundle_stock_availability_validation(self): + from erpnext.accounts.doctype.pos_invoice.pos_invoice import ProductBundleStockValidationError + from erpnext.accounts.doctype.pos_invoice_merge_log.test_pos_invoice_merge_log import ( + init_user_and_profile, + ) + from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle + from erpnext.stock.doctype.item.test_item import create_item + + init_user_and_profile() + + frappe.set_user("Administrator") + + warehouse = "_Test Warehouse - _TC" + company = "_Test Company" + + # Create stock sub-items + sub_item_a = "_Test Bundle SubA" + if not frappe.db.exists("Item", sub_item_a): + create_item( + item_code=sub_item_a, + is_stock_item=1, + ) + + sub_item_b = "_Test Bundle SubB" + if not frappe.db.exists("Item", sub_item_b): + create_item( + item_code=sub_item_b, + is_stock_item=1, + ) + + # Add initial stock: SubA=5, SubB=2 + make_stock_entry(item_code=sub_item_a, target=warehouse, qty=5, company=company) + make_stock_entry(item_code=sub_item_b, target=warehouse, qty=2, company=company) + + # Create Product Bundle: Test Bundle (SubA x2 + SubB x1) + bundle_item = "_Test Bundle" + if not frappe.db.exists("Item", bundle_item): + create_item( + item_code=bundle_item, + is_stock_item=0, + ) + + if not frappe.db.exists("Product Bundle", bundle_item): + make_product_bundle(parent=bundle_item, items=[sub_item_a, sub_item_b]) + + # Test Case 1: Sufficient stock (bundle qty=1: requires SubA=2 (<=5), SubB=1 (<=2)) -> No error + pos_inv_sufficient = create_pos_invoice( + item=bundle_item, + qty=1, + rate=100, + warehouse=warehouse, + pos_profile=self.pos_profile.name, + do_not_save=1, + ) + pos_inv_sufficient.append("payments", {"mode_of_payment": "Cash", "amount": 100, "default": 1}) + pos_inv_sufficient.insert() + pos_inv_sufficient.submit() + + pos_inv_sufficient.cancel() + pos_inv_sufficient.delete() + + # Test Case 2: Insufficient stock (reduce SubB to 1, bundle qty=2: requires SubB=2 >1) -> Error with details + make_stock_entry(item_code=sub_item_b, from_warehouse=warehouse, qty=1, company=company) + + pos_inv_insufficient = create_pos_invoice( + item=bundle_item, + qty=2, + rate=100, + warehouse=warehouse, + pos_profile=self.pos_profile.name, + do_not_save=1, + ) + pos_inv_insufficient.append("payments", {"mode_of_payment": "Cash", "amount": 200, "default": 1}) + pos_inv_insufficient.save() + self.assertRaises(ProductBundleStockValidationError, pos_inv_insufficient.submit) + + frappe.set_user("test@example.com") + def create_pos_invoice(**args): args = frappe._dict(args) From 0458c548ec82377b7f185b5c3dd9cb29fdb277d8 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Tue, 2 Dec 2025 17:14:00 +0530 Subject: [PATCH 3/4] chore: resolve conflict --- erpnext/accounts/doctype/pos_invoice/pos_invoice.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index ffa441aa74f..caea353502e 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -21,11 +21,12 @@ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.stock_ledger import is_negative_stock_allowed -<<<<<<< HEAD + class PartialPaymentValidationError(frappe.ValidationError): -======= + pass + + class ProductBundleStockValidationError(frappe.ValidationError): ->>>>>>> 2612152456 (test(pos): add test for product bundle negative stock validation) pass From 0a64e43e927036db81f9047984e86e07e3aba06f Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Tue, 2 Dec 2025 21:04:00 +0530 Subject: [PATCH 4/4] chore: resolve linter issue --- erpnext/accounts/doctype/pos_invoice/pos_invoice.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index caea353502e..0e04592aeac 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -21,7 +21,6 @@ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.stock_ledger import is_negative_stock_allowed - class PartialPaymentValidationError(frappe.ValidationError): pass