From 38b453630044ec4151da5aef3dcbae944e2c9013 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 1 Dec 2025 19:23:35 +0530 Subject: [PATCH 1/2] fix(pos): add negative stock validation for product bundle --- .../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 d4825b87212..9f2d6431fba 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -20,6 +20,7 @@ from erpnext.accounts.party import get_due_date, get_party_account from erpnext.controllers.queries import item_query as _item_query from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +from erpnext.stock.stock_ledger import is_negative_stock_allowed class POSInvoice(SalesInvoice): @@ -395,32 +396,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_is_pos_using_sales_invoice(self): self.invoice_type_in_pos = frappe.db.get_single_value("POS Settings", "invoice_type") @@ -858,8 +893,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) @@ -876,6 +909,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 26121524560057423b344dc54f453e1191e4911a Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 1 Dec 2025 19:57:44 +0530 Subject: [PATCH 2/2] test(pos): add test for product bundle negative stock validation --- .../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 9f2d6431fba..503c19c7ff8 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -23,6 +23,10 @@ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.stock_ledger import is_negative_stock_allowed +class ProductBundleStockValidationError(frappe.ValidationError): + pass + + class POSInvoice(SalesInvoice): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -434,6 +438,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 a1282f07717..58985195867 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -1024,6 +1024,84 @@ class TestPOSInvoice(IntegrationTestCase): 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)