From 60913b722a3beafe6035e8ad426dcb28e601704e Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Thu, 17 Sep 2026 11:08:07 +0530 Subject: [PATCH] fix: retain bom components without warehouse stock (#59116) Co-authored-by: Mihir Kandoi --- .../bom_stock_analysis/bom_stock_analysis.py | 7 ++-- .../test_bom_stock_analysis.py | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py index 425754a3695..900644f6a06 100644 --- a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py @@ -229,11 +229,10 @@ def get_bom_data(filters): bom_item = frappe.qb.DocType(bom_item_table) stock_qty = get_stock_qty_by_item(filters).as_("stock_qty") - base = frappe.qb.from_(bom_item) - base = base.join(stock_qty) if filters.get("warehouse") else base.left_join(stock_qty) - query = ( - base.on(bom_item.item_code == stock_qty.item_code) + frappe.qb.from_(bom_item) + .left_join(stock_qty) + .on(bom_item.item_code == stock_qty.item_code) .select( bom_item.item_code, # non-grouped columns are constant per grouped item_code -> Max() keeps the GROUP BY valid diff --git a/erpnext/manufacturing/report/bom_stock_analysis/test_bom_stock_analysis.py b/erpnext/manufacturing/report/bom_stock_analysis/test_bom_stock_analysis.py index 3a76f697f58..1ec333486d0 100644 --- a/erpnext/manufacturing/report/bom_stock_analysis/test_bom_stock_analysis.py +++ b/erpnext/manufacturing/report/bom_stock_analysis/test_bom_stock_analysis.py @@ -84,6 +84,29 @@ class TestBOMStockAnalysis(ERPNextTestSuite): ) self.assertEqual(footer.get("description"), expected_min) + def test_components_without_stock_in_selected_warehouse_remain_visible(self): + group = create_warehouse("_Test BOM Stock Analysis Group", {"is_group": 1}) + warehouse = create_warehouse("_Test BOM Stock Analysis Stores", {"parent_warehouse": group}) + stocked_item, missing_item = self.rm_items + create_stock_reconciliation(item_code=stocked_item, warehouse=warehouse, qty=100, rate=100) + self.assertFalse(frappe.db.exists("Bin", {"item_code": missing_item, "warehouse": warehouse})) + + for selected_warehouse in (warehouse, group): + for exploded in (False, True): + with self.subTest(warehouse=selected_warehouse, exploded=exploded): + items, footer = run_report(self.boms[0].name, selected_warehouse, exploded, qty_to_make=1) + self.assertEqual(set(items), {stocked_item, missing_item}) + self.assertEqual(items[stocked_item]["available_qty"], fmt_qty(100)) + self.assertEqual(items[missing_item]["available_qty"], fmt_qty(0)) + self.assertEqual(items[missing_item]["required_qty"], fmt_qty(10)) + self.assertEqual(items[missing_item]["difference_qty"], fmt_qty(-10)) + self.assertEqual(footer["description"], 0) + + items, footer = run_report(self.boms[0].name, warehouse, exploded=False, qty_to_make=0) + self.assertEqual(set(items), {stocked_item, missing_item}) + self.assertEqual(items[missing_item]["available_qty"], fmt_qty(0)) + self.assertEqual(footer["description"], 0) + def _build_duplicate_component_bom(self, phantom_first): """Parent BOM that lists one `component` twice, once via a phantom sub-BOM and once via a non-phantom sub-BOM. `phantom_first` controls which line is at idx 1. Returns the names of @@ -187,6 +210,18 @@ class TestBOMStockAnalysis(ERPNextTestSuite): self.assertAlmostEqual(flt(rows[0].actual_qty), 10.0, places=6) +def run_report(bom, warehouse, exploded, qty_to_make): + """Component rows keyed by item code, plus the footer row.""" + filters = { + "bom": bom, + "warehouse": warehouse, + "show_exploded_view": exploded, + "qty_to_make": qty_to_make, + } + data, footer = split_data_and_footer(bom_stock_analysis_report(filters)[1]) + return {row["item"]: row for row in data}, footer + + def split_data_and_footer(raw_data): """Separate component rows from the footer row. Skips blank spacer rows.""" data = [row for row in raw_data if row and not row.get("bold")]