From 984d744ac26ef5f5af776a296a3fb8310fec2716 Mon Sep 17 00:00:00 2001 From: Lewis Date: Mon, 11 Aug 2025 15:27:18 -0400 Subject: [PATCH 1/7] fix(pos): include Product Bundle components in reserved qty to prevent overselling - Add `get_bundle_pos_reserved_qty` to account for component items in submitted POS Invoices - Update `get_pos_reserved_qty` to sum direct and bundle reservations - Remove double subtraction in `get_bundle_availability` to avoid underestimating bundle availability - Prevents overselling when multiple POS sessions sell bundles with shared components - Fixes #49021 --- .../doctype/pos_invoice/pos_invoice.py | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index f8516d6932d..c775c437dae 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -868,9 +868,8 @@ def get_bundle_availability(bundle_item_code, warehouse): for item in product_bundle.items: item_bin_qty = get_bin_qty(item.item_code, warehouse) item_pos_reserved_qty = get_pos_reserved_qty(item.item_code, warehouse) - available_qty = item_bin_qty - item_pos_reserved_qty - max_available_bundles = available_qty / item.qty + max_available_bundles = item_bin_qty / item.qty if bundle_bin_qty > max_available_bundles and frappe.get_value( "Item", item.item_code, "is_stock_item" ): @@ -893,9 +892,27 @@ def get_bin_qty(item_code, warehouse): def get_pos_reserved_qty(item_code, warehouse): + """ + Calculate total quantity reserved for the given item and warehouse. + + Includes: + - Direct sales of the item in submitted POS Invoices + - Sales of the item as a component of a Product Bundle + + Excludes consolidated invoices (already merged into Sales Invoices via + POS Closing Entry). Used to reflect near real-time availability in the + POS UI and to prevent overselling while multiple sessions may be active. + """ + direct_reserved = get_direct_pos_reserved_qty(item_code, warehouse) + bundle_reserved = get_bundle_pos_reserved_qty(item_code, warehouse) + + return direct_reserved + bundle_reserved + +def get_direct_pos_reserved_qty(item_code, warehouse): + """Reserved qty for the item from direct lines in submitted POS Invoices (matching warehouse).""" + p_inv = frappe.qb.DocType("POS Invoice") p_item = frappe.qb.DocType("POS Invoice Item") - reserved_qty = ( frappe.qb.from_(p_inv) .from_(p_item) @@ -908,9 +925,33 @@ def get_pos_reserved_qty(item_code, warehouse): & (p_item.warehouse == warehouse) ) ).run(as_dict=True) - return flt(reserved_qty[0].stock_qty) if reserved_qty else 0 +def get_bundle_pos_reserved_qty(item_code, warehouse): + """Reserved qty for the item as a component of Product Bundles in submitted POS Invoices (matching warehouse).""" + + p_inv = frappe.qb.DocType("POS Invoice") + p_item = frappe.qb.DocType("POS Invoice Item") + pb = frappe.qb.DocType("Product Bundle") + pb_item = frappe.qb.DocType("Product Bundle Item") + + bundle_reserved = ( + frappe.qb.from_(p_inv) + .from_(p_item) + .from_(pb) + .from_(pb_item) + .select(Sum(p_item.stock_qty * pb_item.qty).as_("stock_qty")) + .where( + (p_inv.name == p_item.parent) + & (IfNull(p_inv.consolidated_invoice, "") == "") + & (p_item.docstatus == 1) + & (p_item.warehouse == warehouse) + & (pb.name == p_item.item_code) # POS item is a bundle + & (pb_item.parent == pb.name) # Bundle items + & (pb_item.item_code == item_code) # This specific item + ) + ).run(as_dict=True) + return flt(bundle_reserved[0].stock_qty) if bundle_reserved else 0 @frappe.whitelist() def make_sales_return(source_name, target_doc=None): From 5a5804ca87cf14fb271798295df4eb6fc955656e Mon Sep 17 00:00:00 2001 From: Lewis Mojica Date: Mon, 11 Aug 2025 16:06:08 -0400 Subject: [PATCH 2/7] chore: remove unused variable --- 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 c775c437dae..5e6500dabae 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -867,7 +867,6 @@ def get_bundle_availability(bundle_item_code, warehouse): bundle_bin_qty = 1000000 for item in product_bundle.items: item_bin_qty = get_bin_qty(item.item_code, warehouse) - item_pos_reserved_qty = get_pos_reserved_qty(item.item_code, warehouse) max_available_bundles = item_bin_qty / item.qty if bundle_bin_qty > max_available_bundles and frappe.get_value( From 0fc187adc3790dd1cc3c91cb7b1bce524cbce16c Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 12 Aug 2025 16:36:00 -0400 Subject: [PATCH 3/7] chore: apply pre-commit formatting --- erpnext/accounts/doctype/pos_invoice/pos_invoice.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 5e6500dabae..99c3dca704c 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -907,6 +907,7 @@ def get_pos_reserved_qty(item_code, warehouse): return direct_reserved + bundle_reserved + def get_direct_pos_reserved_qty(item_code, warehouse): """Reserved qty for the item from direct lines in submitted POS Invoices (matching warehouse).""" @@ -926,6 +927,7 @@ def get_direct_pos_reserved_qty(item_code, warehouse): ).run(as_dict=True) return flt(reserved_qty[0].stock_qty) if reserved_qty else 0 + def get_bundle_pos_reserved_qty(item_code, warehouse): """Reserved qty for the item as a component of Product Bundles in submitted POS Invoices (matching warehouse).""" @@ -945,13 +947,14 @@ def get_bundle_pos_reserved_qty(item_code, warehouse): & (IfNull(p_inv.consolidated_invoice, "") == "") & (p_item.docstatus == 1) & (p_item.warehouse == warehouse) - & (pb.name == p_item.item_code) # POS item is a bundle - & (pb_item.parent == pb.name) # Bundle items - & (pb_item.item_code == item_code) # This specific item + & (pb.name == p_item.item_code) # POS item is a bundle + & (pb_item.parent == pb.name) # Bundle items + & (pb_item.item_code == item_code) # This specific item ) ).run(as_dict=True) return flt(bundle_reserved[0].stock_qty) if bundle_reserved else 0 + @frappe.whitelist() def make_sales_return(source_name, target_doc=None): from erpnext.controllers.sales_and_purchase_return import make_return_doc From a65b200eb7856a5907f9a2af8b6cf1192ec753a5 Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 12 Aug 2025 17:17:08 -0400 Subject: [PATCH 4/7] fix(pos): populate packed_items table in POS Invoice --- erpnext/accounts/doctype/pos_invoice/pos_invoice.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 99c3dca704c..a375920fa98 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -217,6 +217,7 @@ class POSInvoice(SalesInvoice): self.validate_loyalty_transaction() self.validate_company_with_pos_company() self.validate_full_payment() + self.update_packing_list() if self.coupon_code: from erpnext.accounts.doctype.pricing_rule.utils import validate_coupon_code From f5e5f7b588ce1f7bdb88575de6c565a40973132d Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 12 Aug 2025 18:53:09 -0400 Subject: [PATCH 5/7] fix: remove unclear message related to availability of product bundle --- erpnext/accounts/doctype/pos_invoice/pos_invoice.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index a375920fa98..2f12c749e1c 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -411,9 +411,9 @@ class POSInvoice(SalesInvoice): ) elif is_stock_item and flt(available_stock) < flt(d.stock_qty): frappe.throw( - _( - "Row #{}: Stock quantity not enough for Item Code: {} under warehouse {}. Available quantity {}." - ).format(d.idx, item_code, warehouse, available_stock), + _("Row #{}: Stock quantity not enough for Item Code: {} under warehouse {}.").format( + d.idx, item_code, warehouse + ), title=_("Item Unavailable"), ) From d77d79e01173d1831b67e21e3850fc931e834f4d Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 12 Aug 2025 20:10:39 -0400 Subject: [PATCH 6/7] fix(pos): use packed_items snapshot for bundle reservations Replaced live Product Bundle queries with `Packed Item` table lookups to ensure historical reservation accuracy. Addresses bundle qty underestimation and avoids errors when bundle definitions change after sale. Inspired by approach from @diptanilsaha in #49106. --- .../doctype/pos_invoice/pos_invoice.py | 62 ++++++++----------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 2f12c749e1c..87b6a04b7f9 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -903,21 +903,39 @@ def get_pos_reserved_qty(item_code, warehouse): POS Closing Entry). Used to reflect near real-time availability in the POS UI and to prevent overselling while multiple sessions may be active. """ - direct_reserved = get_direct_pos_reserved_qty(item_code, warehouse) - bundle_reserved = get_bundle_pos_reserved_qty(item_code, warehouse) + pinv_item_reserved_qty = get_pos_reserved_qty_from_table("POS Invoice Item", item_code, warehouse) + packed_item_reserved_qty = get_pos_reserved_qty_from_table("Packed Item", item_code, warehouse) - return direct_reserved + bundle_reserved + reserved_qty = flt(pinv_item_reserved_qty[0].stock_qty) if pinv_item_reserved_qty else 0 + reserved_qty += flt(packed_item_reserved_qty[0].stock_qty) if packed_item_reserved_qty else 0 + + return reserved_qty -def get_direct_pos_reserved_qty(item_code, warehouse): - """Reserved qty for the item from direct lines in submitted POS Invoices (matching warehouse).""" +def get_pos_reserved_qty_from_table(child_table, item_code, warehouse): + """ + Get the total reserved quantity for a given item in POS Invoices + from a specific child table. + Args: + child_table (str): Name of the child table to query + (e.g., "POS Invoice Item", "Packed Item"). + item_code (str): The Item Code to filter by. + warehouse (str): The Warehouse to filter by. + + Returns: + float: The total reserved quantity for the item in the given + warehouse from submitted, unconsolidated POS Invoices. + """ p_inv = frappe.qb.DocType("POS Invoice") - p_item = frappe.qb.DocType("POS Invoice Item") - reserved_qty = ( + p_item = frappe.qb.DocType(child_table) + + qty_column = "qty" if child_table == "Packed Item" else "stock_qty" + + stock_qty = ( frappe.qb.from_(p_inv) .from_(p_item) - .select(Sum(p_item.stock_qty).as_("stock_qty")) + .select(Sum(p_item[qty_column]).as_("stock_qty")) .where( (p_inv.name == p_item.parent) & (IfNull(p_inv.consolidated_invoice, "") == "") @@ -926,34 +944,8 @@ def get_direct_pos_reserved_qty(item_code, warehouse): & (p_item.warehouse == warehouse) ) ).run(as_dict=True) - return flt(reserved_qty[0].stock_qty) if reserved_qty else 0 - -def get_bundle_pos_reserved_qty(item_code, warehouse): - """Reserved qty for the item as a component of Product Bundles in submitted POS Invoices (matching warehouse).""" - - p_inv = frappe.qb.DocType("POS Invoice") - p_item = frappe.qb.DocType("POS Invoice Item") - pb = frappe.qb.DocType("Product Bundle") - pb_item = frappe.qb.DocType("Product Bundle Item") - - bundle_reserved = ( - frappe.qb.from_(p_inv) - .from_(p_item) - .from_(pb) - .from_(pb_item) - .select(Sum(p_item.stock_qty * pb_item.qty).as_("stock_qty")) - .where( - (p_inv.name == p_item.parent) - & (IfNull(p_inv.consolidated_invoice, "") == "") - & (p_item.docstatus == 1) - & (p_item.warehouse == warehouse) - & (pb.name == p_item.item_code) # POS item is a bundle - & (pb_item.parent == pb.name) # Bundle items - & (pb_item.item_code == item_code) # This specific item - ) - ).run(as_dict=True) - return flt(bundle_reserved[0].stock_qty) if bundle_reserved else 0 + return stock_qty @frappe.whitelist() From 54d3e5675f9d219729f38b0af8693066a734c088 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 13 Aug 2025 16:25:58 -0400 Subject: [PATCH 7/7] chore: improve code clarity per reviewer feedback - Rename stock_qty variable to reserved_qty for clarity - Update get_pos_reserved_qty_from_table to return float - Simplify aggregation logic in get_pos_reserved_qty - Ensure return values match docstring specifications --- erpnext/accounts/doctype/pos_invoice/pos_invoice.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 87b6a04b7f9..aac18100263 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -906,8 +906,7 @@ def get_pos_reserved_qty(item_code, warehouse): pinv_item_reserved_qty = get_pos_reserved_qty_from_table("POS Invoice Item", item_code, warehouse) packed_item_reserved_qty = get_pos_reserved_qty_from_table("Packed Item", item_code, warehouse) - reserved_qty = flt(pinv_item_reserved_qty[0].stock_qty) if pinv_item_reserved_qty else 0 - reserved_qty += flt(packed_item_reserved_qty[0].stock_qty) if packed_item_reserved_qty else 0 + reserved_qty = pinv_item_reserved_qty + packed_item_reserved_qty return reserved_qty @@ -932,7 +931,7 @@ def get_pos_reserved_qty_from_table(child_table, item_code, warehouse): qty_column = "qty" if child_table == "Packed Item" else "stock_qty" - stock_qty = ( + reserved_qty = ( frappe.qb.from_(p_inv) .from_(p_item) .select(Sum(p_item[qty_column]).as_("stock_qty")) @@ -945,7 +944,7 @@ def get_pos_reserved_qty_from_table(child_table, item_code, warehouse): ) ).run(as_dict=True) - return stock_qty + return flt(reserved_qty[0].stock_qty) if reserved_qty else 0 @frappe.whitelist()