From 38b453630044ec4151da5aef3dcbae944e2c9013 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 1 Dec 2025 19:23:35 +0530 Subject: [PATCH] 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)