From 5d088350dc328e4551a457dd89a35f6ec46599b4 Mon Sep 17 00:00:00 2001 From: nishkagosalia Date: Mon, 23 Mar 2026 16:18:59 +0530 Subject: [PATCH 1/4] feat: Bom stock analysis report --- .../report/bom_stock_analysis/__init__.py | 0 .../bom_stock_analysis/bom_stock_analysis.js | 43 +++ .../bom_stock_analysis.json | 31 ++ .../bom_stock_analysis/bom_stock_analysis.py | 282 ++++++++++++++++++ .../test_bom_stock_analysis.py | 119 ++++++++ 5 files changed, 475 insertions(+) create mode 100644 erpnext/manufacturing/report/bom_stock_analysis/__init__.py create mode 100644 erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js create mode 100644 erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.json create mode 100644 erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py create mode 100644 erpnext/manufacturing/report/bom_stock_analysis/test_bom_stock_analysis.py diff --git a/erpnext/manufacturing/report/bom_stock_analysis/__init__.py b/erpnext/manufacturing/report/bom_stock_analysis/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js new file mode 100644 index 00000000000..d97392a5afd --- /dev/null +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js @@ -0,0 +1,43 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.query_reports["BOM Stock Analysis"] = { + filters: [ + { + fieldname: "bom", + label: __("BOM"), + fieldtype: "Link", + options: "BOM", + reqd: 1, + }, + { + fieldname: "warehouse", + label: __("Warehouse"), + fieldtype: "Link", + options: "Warehouse", + }, + { + fieldname: "qty_to_make", + label: __("FG Items to Make"), + fieldtype: "Float", + }, + { + fieldname: "show_exploded_view", + label: __("Show availability of exploded items"), + fieldtype: "Check", + default: false, + }, + ], + formatter: function (value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + + if (column.id == "producible_fg_item") { + if (data["producible_fg_item"] >= data["required_qty"]) { + value = `${data["producible_fg_item"]}`; + } else { + value = `${data["producible_fg_item"]}`; + } + } + return value; + }, +}; diff --git a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.json b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.json new file mode 100644 index 00000000000..b0e68f77ba7 --- /dev/null +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.json @@ -0,0 +1,31 @@ +{ + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2026-03-23 15:42:06.064606", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letter_head": null, + "modified": "2026-03-23 15:48:56.933892", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "BOM Stock Analysis", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "BOM", + "report_name": "BOM Stock Analysis", + "report_type": "Script Report", + "roles": [ + { + "role": "Manufacturing Manager" + }, + { + "role": "Manufacturing User" + } + ], + "timeout": 0 +} diff --git a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py new file mode 100644 index 00000000000..d3220ee35b5 --- /dev/null +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py @@ -0,0 +1,282 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.query_builder.functions import Floor, IfNull, Sum +from frappe.utils.data import comma_and +from pypika.terms import ExistsCriterion + + +def execute(filters=None): + qty_to_make = filters.get("qty_to_make") + + if qty_to_make: + columns = get_columns_with_qty_to_make() + data = get_data_with_qty_to_make(filters) + return columns, data + else: + data = [] + columns = get_columns_without_qty_to_make() + bom_data = get_producible_fg_items(filters) + for row in bom_data: + data.append(row) + + return columns, data + + +def get_data_with_qty_to_make(filters): + data = [] + bom_data = get_bom_data(filters) + manufacture_details = get_manufacturer_records() + + for row in bom_data: + required_qty = filters.get("qty_to_make") * row.qty_per_unit + last_purchase_rate = frappe.db.get_value("Item", row.item_code, "last_purchase_rate") + + data.append(get_report_data(last_purchase_rate, required_qty, row, manufacture_details)) + + return data + + +def get_report_data(last_purchase_rate, required_qty, row, manufacture_details): + qty_per_unit = row.qty_per_unit if row.qty_per_unit > 0 else 0 + difference_qty = row.actual_qty - required_qty + return [ + row.item_code, + row.description, + row.from_bom_no, + comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer", []), add_quotes=False), + comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer_part", []), add_quotes=False), + qty_per_unit, + row.actual_qty, + required_qty, + difference_qty, + last_purchase_rate, + row.actual_qty // qty_per_unit if qty_per_unit else 0, + ] + + +def get_columns_with_qty_to_make(): + return [ + { + "fieldname": "item", + "label": _("Item"), + "fieldtype": "Link", + "options": "Item", + "width": 120, + }, + { + "fieldname": "description", + "label": _("Description"), + "fieldtype": "Data", + "width": 150, + }, + { + "fieldname": "from_bom_no", + "label": _("From BOM No"), + "fieldtype": "Link", + "options": "BOM", + "width": 150, + }, + { + "fieldname": "manufacturer", + "label": _("Manufacturer"), + "fieldtype": "Data", + "width": 120, + }, + { + "fieldname": "manufacturer_part_number", + "label": _("Manufacturer Part Number"), + "fieldtype": "Data", + "width": 150, + }, + { + "fieldname": "qty_per_unit", + "label": _("Qty Per Unit"), + "fieldtype": "Float", + "width": 110, + }, + { + "fieldname": "available_qty", + "label": _("Available Qty"), + "fieldtype": "Float", + "width": 120, + }, + { + "fieldname": "required_qty", + "label": _("Required Qty"), + "fieldtype": "Float", + "width": 120, + }, + { + "fieldname": "difference_qty", + "label": _("Difference Qty"), + "fieldtype": "Float", + "width": 130, + }, + { + "fieldname": "last_purchase_rate", + "label": _("Last Purchase Rate"), + "fieldtype": "Float", + "width": 160, + }, + { + "fieldname": "producible_fg_item", + "label": _("Producible FG Item"), + "fieldtype": "Float", + "width": 200, + }, + ] + + +def get_columns_without_qty_to_make(): + return [ + _("Item") + ":Link/Item:150", + _("Item Name") + "::240", + _("Description") + "::300", + _("From BOM No") + "::200", + _("Required Qty") + ":Float:160", + _("Producible FG Item") + ":Float:200", + ] + + +def get_bom_data(filters): + bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item" + + bom_item = frappe.qb.DocType(bom_item_table) + bin = frappe.qb.DocType("Bin") + + query = ( + frappe.qb.from_(bom_item) + .left_join(bin) + .on(bom_item.item_code == bin.item_code) + .select( + bom_item.item_code, + bom_item.description, + bom_item.parent.as_("from_bom_no"), + bom_item.qty_consumed_per_unit.as_("qty_per_unit"), + IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"), + ) + .where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM")) + .groupby(bom_item.item_code) + .orderby(bom_item.idx) + ) + + if filters.get("warehouse"): + warehouse_details = frappe.db.get_value( + "Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1 + ) + + if warehouse_details: + wh = frappe.qb.DocType("Warehouse") + query = query.where( + ExistsCriterion( + frappe.qb.from_(wh) + .select(wh.name) + .where( + (wh.lft >= warehouse_details.lft) + & (wh.rgt <= warehouse_details.rgt) + & (bin.warehouse == wh.name) + ) + ) + ) + else: + query = query.where(bin.warehouse == filters.get("warehouse")) + + if bom_item_table == "BOM Item": + query = query.select(bom_item.bom_no, bom_item.is_phantom_item) + + data = query.run(as_dict=True) + return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data + + +def explode_phantom_boms(data, filters): + original_bom = filters.get("bom") + replacements = [] + + for idx, item in enumerate(data): + if not item.is_phantom_item: + continue + + filters["bom"] = item.bom_no + children = get_bom_data(filters) + filters["bom"] = original_bom + + for child in children: + child.qty_per_unit = (child.qty_per_unit or 0) * (item.qty_per_unit or 0) + + replacements.append((idx, children)) + + for idx, children in reversed(replacements): + data.pop(idx) + data[idx:idx] = children + + filters["bom"] = original_bom + return data + + +def get_manufacturer_records(): + details = frappe.get_all( + "Item Manufacturer", fields=["manufacturer", "manufacturer_part_no", "item_code"] + ) + + manufacture_details = frappe._dict() + for detail in details: + dic = manufacture_details.setdefault(detail.get("item_code"), {}) + dic.setdefault("manufacturer", []).append(detail.get("manufacturer")) + dic.setdefault("manufacturer_part", []).append(detail.get("manufacturer_part_no")) + + return manufacture_details + + +def get_producible_fg_items(filters): + BOM_ITEM = frappe.qb.DocType("BOM Item") + BOM = frappe.qb.DocType("BOM") + BIN = frappe.qb.DocType("Bin") + WH = frappe.qb.DocType("Warehouse") + + warehouse = filters.get("warehouse") + warehouse_details = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"], as_dict=1) + + if not warehouse: + frappe.throw(_("Warehouse is required to get producible FG Items")) + + if warehouse_details: + bin_subquery = ( + frappe.qb.from_(BIN) + .join(WH) + .on(BIN.warehouse == WH.name) + .select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty")) + .where((WH.lft >= warehouse_details.lft) & (WH.rgt <= warehouse_details.rgt)) + .groupby(BIN.item_code) + ) + else: + bin_subquery = ( + frappe.qb.from_(BIN) + .select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty")) + .where(BIN.warehouse == warehouse) + .groupby(BIN.item_code) + ) + + query = ( + frappe.qb.from_(BOM_ITEM) + .join(BOM) + .on(BOM_ITEM.parent == BOM.name) + .left_join(bin_subquery) + .on(BOM_ITEM.item_code == bin_subquery.item_code) + .select( + BOM_ITEM.item_code, + BOM_ITEM.item_name, + BOM_ITEM.description, + BOM_ITEM.parent.as_("from_bom_no"), + (BOM_ITEM.stock_qty / BOM.quantity).as_("qty_per_unit"), + Floor(bin_subquery.actual_qty / ((Sum(BOM_ITEM.stock_qty)) / BOM.quantity)), + ) + .where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM")) + .groupby(BOM_ITEM.item_code) + .orderby(BOM_ITEM.idx) + ) + + data = query.run(as_list=True) + return data 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 new file mode 100644 index 00000000000..ebb1b85ac53 --- /dev/null +++ b/erpnext/manufacturing/report/bom_stock_analysis/test_bom_stock_analysis.py @@ -0,0 +1,119 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe + +from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom +from erpnext.manufacturing.report.bom_stock_analysis.bom_stock_analysis import ( + execute as bom_stock_analysis_report, +) +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.tests.utils import ERPNextTestSuite + + +class TestBOMStockAnalysis(ERPNextTestSuite): + def setUp(self): + self.fg_item, self.rm_items = create_items() + self.boms = create_boms(self.fg_item, self.rm_items) + + def test_bom_stock_analysis(self): + qty_to_make = 10 + + # Case 1: When Item(s) Qty and Stock Qty are equal. + data = bom_stock_analysis_report( + filters={ + "qty_to_make": qty_to_make, + "bom": self.boms[0].name, + } + )[1] + expected_data = get_expected_data(self.boms[0], qty_to_make) + self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) + + # Case 2: When Item(s) Qty and Stock Qty are different and BOM Qty is 1. + data = bom_stock_analysis_report( + filters={ + "qty_to_make": qty_to_make, + "bom": self.boms[1].name, + } + )[1] + expected_data = get_expected_data(self.boms[1], qty_to_make) + self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) + + # Case 3: When Item(s) Qty and Stock Qty are different and BOM Qty is greater than 1. + data = bom_stock_analysis_report( + filters={ + "qty_to_make": qty_to_make, + "bom": self.boms[2].name, + } + )[1] + expected_data = get_expected_data(self.boms[2], qty_to_make) + self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) + + +def create_items(): + fg_item = make_item(properties={"is_stock_item": 1}).name + rm_item1 = make_item( + properties={ + "is_stock_item": 1, + "standard_rate": 100, + "opening_stock": 100, + "last_purchase_rate": 100, + "item_defaults": [{"company": "_Test Company", "default_warehouse": "Stores - _TC"}], + } + ).name + rm_item2 = make_item( + properties={ + "is_stock_item": 1, + "standard_rate": 200, + "opening_stock": 200, + "last_purchase_rate": 200, + "item_defaults": [{"company": "_Test Company", "default_warehouse": "Stores - _TC"}], + } + ).name + + return fg_item, [rm_item1, rm_item2] + + +def create_boms(fg_item, rm_items): + def update_bom_items(bom, uom, conversion_factor): + for item in bom.items: + item.uom = uom + item.conversion_factor = conversion_factor + + return bom + + bom1 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10) + + bom2 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10, do_not_submit=True) + bom2 = update_bom_items(bom2, "Box", 10) + bom2.save() + bom2.submit() + + bom3 = make_bom(item=fg_item, quantity=2, raw_materials=rm_items, rm_qty=10, do_not_submit=True) + bom3 = update_bom_items(bom3, "Box", 10) + bom3.save() + bom3.submit() + + return [bom1, bom2, bom3] + + +def get_expected_data(bom, qty_to_make): + expected_data = [] + + for idx in range(len(bom.items)): + expected_data.append( + [ + bom.items[idx].item_code, + bom.items[idx].item_code, + bom.name, + "", + "", + float(bom.items[idx].stock_qty / bom.quantity), + float(100 * (idx + 1)), + float(qty_to_make * (bom.items[idx].stock_qty / bom.quantity)), + float((100 * (idx + 1)) - (qty_to_make * (bom.items[idx].stock_qty / bom.quantity))), + float(100 * (idx + 1)), + ] + ) + + return expected_data From c1874cb7d58f4714f1ef92d705ea53343b4861b9 Mon Sep 17 00:00:00 2001 From: nishkagosalia Date: Tue, 24 Mar 2026 12:02:09 +0530 Subject: [PATCH 2/4] fix: change in functionality --- .../bom_stock_analysis/bom_stock_analysis.js | 28 ++- .../bom_stock_analysis/bom_stock_analysis.py | 237 +++++++++++------- .../test_bom_stock_analysis.py | 104 ++++++-- 3 files changed, 240 insertions(+), 129 deletions(-) diff --git a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js index d97392a5afd..7c6ccfdf743 100644 --- a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js @@ -28,16 +28,32 @@ frappe.query_reports["BOM Stock Analysis"] = { default: false, }, ], - formatter: function (value, row, column, data, default_formatter) { + formatter(value, row, column, data, default_formatter) { + if (data && data.bold && column.fieldname === "item") { + return value ? `${value}` : ""; + } + value = default_formatter(value, row, column, data); - if (column.id == "producible_fg_item") { - if (data["producible_fg_item"] >= data["required_qty"]) { - value = `${data["producible_fg_item"]}`; - } else { - value = `${data["producible_fg_item"]}`; + if (column.fieldname === "difference_qty" && value !== "" && value !== undefined) { + const numeric = parseFloat(value.replace(/,/g, "")) || 0; + if (numeric < 0) { + value = `${value}`; + } else if (numeric > 0) { + value = `${value}`; } } + + if (data && data.bold) { + if (column.fieldname === "description" || column.fieldname === "item_name") { + const qty_to_make = frappe.query_report.get_filter_value("qty_to_make"); + const producible = parseFloat(value) || 0; + const colour = qty_to_make && producible < qty_to_make ? "red" : "green"; + return `${value}`; + } + return `${value}`; + } + return value; }, }; 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 d3220ee35b5..78aa75aa7fa 100644 --- a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py @@ -4,74 +4,101 @@ import frappe from frappe import _ from frappe.query_builder.functions import Floor, IfNull, Sum +from frappe.utils import flt, fmt_money from frappe.utils.data import comma_and from pypika.terms import ExistsCriterion def execute(filters=None): - qty_to_make = filters.get("qty_to_make") - - if qty_to_make: + if filters.get("qty_to_make"): columns = get_columns_with_qty_to_make() data = get_data_with_qty_to_make(filters) - return columns, data else: - data = [] columns = get_columns_without_qty_to_make() - bom_data = get_producible_fg_items(filters) - for row in bom_data: - data.append(row) + data = get_data_without_qty_to_make(filters) - return columns, data + return columns, data + + +def fmt_qty(value): + """Format a float quantity for display as a string, so blank rows stay blank.""" + return frappe.utils.fmt_money(value, precision=2, currency=None) + + +def fmt_rate(value): + """Format a currency rate for display as a string.""" + currency = frappe.defaults.get_global_default("currency") + return frappe.utils.fmt_money(value, precision=2, currency=currency) def get_data_with_qty_to_make(filters): - data = [] bom_data = get_bom_data(filters) manufacture_details = get_manufacturer_records() + purchase_rates = batch_fetch_purchase_rates(bom_data) + qty_to_make = filters.get("qty_to_make") + data = [] for row in bom_data: - required_qty = filters.get("qty_to_make") * row.qty_per_unit - last_purchase_rate = frappe.db.get_value("Item", row.item_code, "last_purchase_rate") + qty_per_unit = flt(row.qty_per_unit) if row.qty_per_unit > 0 else 0 + required_qty = qty_to_make * qty_per_unit + difference_qty = flt(row.actual_qty) - required_qty + rate = purchase_rates.get(row.item_code, 0) - data.append(get_report_data(last_purchase_rate, required_qty, row, manufacture_details)) + data.append( + { + "item": row.item_code, + "description": row.description, + "from_bom_no": row.from_bom_no, + "manufacturer": comma_and( + manufacture_details.get(row.item_code, {}).get("manufacturer", []), add_quotes=False + ), + "manufacturer_part_number": comma_and( + manufacture_details.get(row.item_code, {}).get("manufacturer_part", []), add_quotes=False + ), + "qty_per_unit": fmt_qty(qty_per_unit), + "available_qty": fmt_qty(row.actual_qty), + "required_qty": fmt_qty(required_qty), + "difference_qty": fmt_qty(difference_qty), + "last_purchase_rate": fmt_rate(rate), + "_available_qty": flt(row.actual_qty), + "_qty_per_unit": qty_per_unit, + } + ) + + min_producible = ( + min(int(r["_available_qty"] // r["_qty_per_unit"]) for r in data if r["_qty_per_unit"]) if data else 0 + ) + + for row in data: + row.pop("_available_qty", None) + row.pop("_qty_per_unit", None) + + # blank spacer row + data.append({}) + + data.append( + { + "item": _("Maximum Producible Items"), + "description": min_producible, + "from_bom_no": "", + "manufacturer": "", + "manufacturer_part_number": "", + "qty_per_unit": "", + "available_qty": "", + "required_qty": "", + "difference_qty": "", + "last_purchase_rate": "", + "bold": 1, + } + ) return data -def get_report_data(last_purchase_rate, required_qty, row, manufacture_details): - qty_per_unit = row.qty_per_unit if row.qty_per_unit > 0 else 0 - difference_qty = row.actual_qty - required_qty - return [ - row.item_code, - row.description, - row.from_bom_no, - comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer", []), add_quotes=False), - comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer_part", []), add_quotes=False), - qty_per_unit, - row.actual_qty, - required_qty, - difference_qty, - last_purchase_rate, - row.actual_qty // qty_per_unit if qty_per_unit else 0, - ] - - def get_columns_with_qty_to_make(): return [ - { - "fieldname": "item", - "label": _("Item"), - "fieldtype": "Link", - "options": "Item", - "width": 120, - }, - { - "fieldname": "description", - "label": _("Description"), - "fieldtype": "Data", - "width": 150, - }, + {"fieldname": "item", "label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 180}, + {"fieldname": "description", "label": _("Description"), "fieldtype": "Data", "width": 160}, { "fieldname": "from_bom_no", "label": _("From BOM No"), @@ -79,68 +106,89 @@ def get_columns_with_qty_to_make(): "options": "BOM", "width": 150, }, - { - "fieldname": "manufacturer", - "label": _("Manufacturer"), - "fieldtype": "Data", - "width": 120, - }, + {"fieldname": "manufacturer", "label": _("Manufacturer"), "fieldtype": "Data", "width": 130}, { "fieldname": "manufacturer_part_number", "label": _("Manufacturer Part Number"), "fieldtype": "Data", - "width": 150, - }, - { - "fieldname": "qty_per_unit", - "label": _("Qty Per Unit"), - "fieldtype": "Float", - "width": 110, - }, - { - "fieldname": "available_qty", - "label": _("Available Qty"), - "fieldtype": "Float", - "width": 120, - }, - { - "fieldname": "required_qty", - "label": _("Required Qty"), - "fieldtype": "Float", - "width": 120, - }, - { - "fieldname": "difference_qty", - "label": _("Difference Qty"), - "fieldtype": "Float", - "width": 130, + "width": 170, }, + {"fieldname": "qty_per_unit", "label": _("Qty Per Unit"), "fieldtype": "Data", "width": 110}, + {"fieldname": "available_qty", "label": _("Available Qty"), "fieldtype": "Data", "width": 120}, + {"fieldname": "required_qty", "label": _("Required Qty"), "fieldtype": "Data", "width": 120}, + {"fieldname": "difference_qty", "label": _("Difference Qty"), "fieldtype": "Data", "width": 130}, { "fieldname": "last_purchase_rate", "label": _("Last Purchase Rate"), - "fieldtype": "Float", + "fieldtype": "Data", "width": 160, }, - { - "fieldname": "producible_fg_item", - "label": _("Producible FG Item"), - "fieldtype": "Float", - "width": 200, - }, ] +def get_data_without_qty_to_make(filters): + raw_rows = get_producible_fg_items(filters) + + data = [] + for row in raw_rows: + data.append( + { + "item": row[0], + "description": row[1], + "from_bom_no": row[2], + "qty_per_unit": fmt_qty(row[3]), + "available_qty": fmt_qty(row[4]), + } + ) + + min_producible = min((row[5] or 0) for row in raw_rows) if raw_rows else 0 + # blank spacer row + data.append({}) + + data.append( + { + "item": _("Maximum Producible Items"), + "description": min_producible, + "from_bom_no": "", + "qty_per_unit": "", + "available_qty": "", + "bold": 1, + } + ) + + return data + + def get_columns_without_qty_to_make(): return [ - _("Item") + ":Link/Item:150", - _("Item Name") + "::240", - _("Description") + "::300", - _("From BOM No") + "::200", - _("Required Qty") + ":Float:160", - _("Producible FG Item") + ":Float:200", + {"fieldname": "item", "label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 180}, + {"fieldname": "description", "label": _("Description"), "fieldtype": "Data", "width": 200}, + { + "fieldname": "from_bom_no", + "label": _("From BOM No"), + "fieldtype": "Link", + "options": "BOM", + "width": 160, + }, + {"fieldname": "qty_per_unit", "label": _("Qty Per Unit"), "fieldtype": "Data", "width": 120}, + {"fieldname": "available_qty", "label": _("Available Qty"), "fieldtype": "Data", "width": 120}, ] +def batch_fetch_purchase_rates(bom_data): + if not bom_data: + return {} + item_codes = [row.item_code for row in bom_data] + return { + r.name: r.last_purchase_rate + for r in frappe.get_all( + "Item", + filters={"name": ["in", item_codes]}, + fields=["name", "last_purchase_rate"], + ) + } + + def get_bom_data(filters): bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item" @@ -167,7 +215,6 @@ def get_bom_data(filters): warehouse_details = frappe.db.get_value( "Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1 ) - if warehouse_details: wh = frappe.qb.DocType("Warehouse") query = query.where( @@ -212,7 +259,6 @@ def explode_phantom_boms(data, filters): data.pop(idx) data[idx:idx] = children - filters["bom"] = original_bom return data @@ -220,13 +266,11 @@ def get_manufacturer_records(): details = frappe.get_all( "Item Manufacturer", fields=["manufacturer", "manufacturer_part_no", "item_code"] ) - manufacture_details = frappe._dict() for detail in details: dic = manufacture_details.setdefault(detail.get("item_code"), {}) dic.setdefault("manufacturer", []).append(detail.get("manufacturer")) dic.setdefault("manufacturer_part", []).append(detail.get("manufacturer_part_no")) - return manufacture_details @@ -237,11 +281,11 @@ def get_producible_fg_items(filters): WH = frappe.qb.DocType("Warehouse") warehouse = filters.get("warehouse") - warehouse_details = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"], as_dict=1) - if not warehouse: frappe.throw(_("Warehouse is required to get producible FG Items")) + warehouse_details = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"], as_dict=1) + if warehouse_details: bin_subquery = ( frappe.qb.from_(BIN) @@ -267,10 +311,10 @@ def get_producible_fg_items(filters): .on(BOM_ITEM.item_code == bin_subquery.item_code) .select( BOM_ITEM.item_code, - BOM_ITEM.item_name, BOM_ITEM.description, BOM_ITEM.parent.as_("from_bom_no"), (BOM_ITEM.stock_qty / BOM.quantity).as_("qty_per_unit"), + IfNull(bin_subquery.actual_qty, 0).as_("available_qty"), Floor(bin_subquery.actual_qty / ((Sum(BOM_ITEM.stock_qty)) / BOM.quantity)), ) .where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM")) @@ -278,5 +322,4 @@ def get_producible_fg_items(filters): .orderby(BOM_ITEM.idx) ) - data = query.run(as_list=True) - return data + return query.run(as_list=True) 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 ebb1b85ac53..fd8a52afde0 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 @@ -1,7 +1,7 @@ -# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - import frappe +from frappe.utils import fmt_money from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom from erpnext.manufacturing.report.bom_stock_analysis.bom_stock_analysis import ( @@ -11,6 +11,15 @@ from erpnext.stock.doctype.item.test_item import make_item from erpnext.tests.utils import ERPNextTestSuite +def fmt_qty(value): + return fmt_money(value, precision=2, currency=None) + + +def fmt_rate(value): + currency = frappe.defaults.get_global_default("currency") + return fmt_money(value, precision=2, currency=currency) + + class TestBOMStockAnalysis(ERPNextTestSuite): def setUp(self): self.fg_item, self.rm_items = create_items() @@ -20,34 +29,62 @@ class TestBOMStockAnalysis(ERPNextTestSuite): qty_to_make = 10 # Case 1: When Item(s) Qty and Stock Qty are equal. - data = bom_stock_analysis_report( + raw_data = bom_stock_analysis_report( filters={ "qty_to_make": qty_to_make, "bom": self.boms[0].name, } )[1] - expected_data = get_expected_data(self.boms[0], qty_to_make) - self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) + + data, footer = split_data_and_footer(raw_data) + expected_data, expected_min = get_expected_data(self.boms[0], qty_to_make) + + self.assertSetEqual( + set(tuple(sorted(r.items())) for r in data), + set(tuple(sorted(r.items())) for r in expected_data), + ) + self.assertEqual(footer.get("description"), expected_min) # Case 2: When Item(s) Qty and Stock Qty are different and BOM Qty is 1. - data = bom_stock_analysis_report( + raw_data = bom_stock_analysis_report( filters={ "qty_to_make": qty_to_make, "bom": self.boms[1].name, } )[1] - expected_data = get_expected_data(self.boms[1], qty_to_make) - self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) + + data, footer = split_data_and_footer(raw_data) + expected_data, expected_min = get_expected_data(self.boms[1], qty_to_make) + + self.assertSetEqual( + set(tuple(sorted(r.items())) for r in data), + set(tuple(sorted(r.items())) for r in expected_data), + ) + self.assertEqual(footer.get("description"), expected_min) # Case 3: When Item(s) Qty and Stock Qty are different and BOM Qty is greater than 1. - data = bom_stock_analysis_report( + raw_data = bom_stock_analysis_report( filters={ "qty_to_make": qty_to_make, "bom": self.boms[2].name, } )[1] - expected_data = get_expected_data(self.boms[2], qty_to_make) - self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) + + data, footer = split_data_and_footer(raw_data) + expected_data, expected_min = get_expected_data(self.boms[2], qty_to_make) + + self.assertSetEqual( + set(tuple(sorted(r.items())) for r in data), + set(tuple(sorted(r.items())) for r in expected_data), + ) + self.assertEqual(footer.get("description"), expected_min) + + +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")] + footer = next((row for row in raw_data if row and row.get("bold")), {}) + return data, footer def create_items(): @@ -79,7 +116,6 @@ def create_boms(fg_item, rm_items): for item in bom.items: item.uom = uom item.conversion_factor = conversion_factor - return bom bom1 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10) @@ -98,22 +134,38 @@ def create_boms(fg_item, rm_items): def get_expected_data(bom, qty_to_make): + """ + Returns (component_rows, min_producible). + Component rows are dicts matching what the report produces. + min_producible is the expected footer value. + """ expected_data = [] + producible_per_item = [] + + for idx, bom_item in enumerate(bom.items): + qty_per_unit = float(bom_item.stock_qty / bom.quantity) + available_qty = float(100 * (idx + 1)) + required_qty = float(qty_to_make * qty_per_unit) + difference_qty = available_qty - required_qty + last_purchase_rate = float(100 * (idx + 1)) - for idx in range(len(bom.items)): expected_data.append( - [ - bom.items[idx].item_code, - bom.items[idx].item_code, - bom.name, - "", - "", - float(bom.items[idx].stock_qty / bom.quantity), - float(100 * (idx + 1)), - float(qty_to_make * (bom.items[idx].stock_qty / bom.quantity)), - float((100 * (idx + 1)) - (qty_to_make * (bom.items[idx].stock_qty / bom.quantity))), - float(100 * (idx + 1)), - ] + { + "item": bom_item.item_code, + "description": bom_item.item_code, # description falls back to item_code in test items + "from_bom_no": bom.name, + "manufacturer": "", + "manufacturer_part_number": "", + "qty_per_unit": fmt_qty(qty_per_unit), + "available_qty": fmt_qty(available_qty), + "required_qty": fmt_qty(required_qty), + "difference_qty": fmt_qty(difference_qty), + "last_purchase_rate": fmt_rate(last_purchase_rate), + } ) - return expected_data + producible_per_item.append(int(available_qty // qty_per_unit) if qty_per_unit else 0) + + min_producible = min(producible_per_item) if producible_per_item else 0 + + return expected_data, min_producible From 3bedc6cf7ea69ca016681dd9b2b245d86192aca8 Mon Sep 17 00:00:00 2001 From: nishkagosalia Date: Thu, 26 Mar 2026 15:15:53 +0530 Subject: [PATCH 3/4] chore: Dropping bom stock report and bom stock calculated report --- .../report/bom_stock_calculated/__init__.py | 0 .../bom_stock_calculated.js | 33 --- .../bom_stock_calculated.json | 26 --- .../bom_stock_calculated.py | 199 ------------------ .../test_bom_stock_calculated.py | 117 ---------- .../report/bom_stock_report/__init__.py | 0 .../bom_stock_report/bom_stock_report.html | 27 --- .../bom_stock_report/bom_stock_report.js | 41 ---- .../bom_stock_report/bom_stock_report.json | 28 --- .../bom_stock_report/bom_stock_report.py | 106 ---------- .../bom_stock_report/test_bom_stock_report.py | 112 ---------- 11 files changed, 689 deletions(-) delete mode 100644 erpnext/manufacturing/report/bom_stock_calculated/__init__.py delete mode 100644 erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.js delete mode 100644 erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.json delete mode 100644 erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py delete mode 100644 erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py delete mode 100644 erpnext/manufacturing/report/bom_stock_report/__init__.py delete mode 100644 erpnext/manufacturing/report/bom_stock_report/bom_stock_report.html delete mode 100644 erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js delete mode 100644 erpnext/manufacturing/report/bom_stock_report/bom_stock_report.json delete mode 100644 erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py delete mode 100644 erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py diff --git a/erpnext/manufacturing/report/bom_stock_calculated/__init__.py b/erpnext/manufacturing/report/bom_stock_calculated/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.js b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.js deleted file mode 100644 index 76a95127853..00000000000 --- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.js +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2016, Epoch Consulting and contributors -// For license information, please see license.txt - -frappe.query_reports["BOM Stock Calculated"] = { - filters: [ - { - fieldname: "bom", - label: __("BOM"), - fieldtype: "Link", - options: "BOM", - reqd: 1, - }, - { - fieldname: "warehouse", - label: __("Warehouse"), - fieldtype: "Link", - options: "Warehouse", - }, - { - fieldname: "qty_to_make", - label: __("Quantity to Make"), - fieldtype: "Float", - default: "1.0", - reqd: 1, - }, - { - fieldname: "show_exploded_view", - label: __("Show exploded view"), - fieldtype: "Check", - default: false, - }, - ], -}; diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.json b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.json deleted file mode 100644 index 73421cebf0e..00000000000 --- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "add_total_row": 0, - "creation": "2018-05-17 12:40:31.355049", - "disabled": 0, - "docstatus": 0, - "doctype": "Report", - "idx": 0, - "is_standard": "Yes", - "letter_head": "", - "modified": "2018-06-18 13:33:18.103220", - "modified_by": "Administrator", - "module": "Manufacturing", - "name": "BOM Stock Calculated", - "owner": "Administrator", - "ref_doctype": "BOM", - "report_name": "BOM Stock Calculated", - "report_type": "Script Report", - "roles": [ - { - "role": "Manufacturing Manager" - }, - { - "role": "Manufacturing User" - } - ] -} diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py deleted file mode 100644 index 4b5df4df4b2..00000000000 --- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py +++ /dev/null @@ -1,199 +0,0 @@ -# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -import frappe -from frappe import _ -from frappe.query_builder.functions import IfNull, Sum -from frappe.utils.data import comma_and -from pypika.terms import ExistsCriterion - - -def execute(filters=None): - columns = get_columns() - data = [] - - bom_data = get_bom_data(filters) - qty_to_make = filters.get("qty_to_make") - manufacture_details = get_manufacturer_records() - - for row in bom_data: - required_qty = qty_to_make * row.qty_per_unit - last_purchase_rate = frappe.db.get_value("Item", row.item_code, "last_purchase_rate") - - data.append(get_report_data(last_purchase_rate, required_qty, row, manufacture_details)) - - return columns, data - - -def get_report_data(last_purchase_rate, required_qty, row, manufacture_details): - qty_per_unit = row.qty_per_unit if row.qty_per_unit > 0 else 0 - difference_qty = row.actual_qty - required_qty - return [ - row.item_code, - row.description, - row.from_bom_no, - comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer", []), add_quotes=False), - comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer_part", []), add_quotes=False), - qty_per_unit, - row.actual_qty, - required_qty, - difference_qty, - last_purchase_rate, - ] - - -def get_columns(): - return [ - { - "fieldname": "item", - "label": _("Item"), - "fieldtype": "Link", - "options": "Item", - "width": 120, - }, - { - "fieldname": "description", - "label": _("Description"), - "fieldtype": "Data", - "width": 150, - }, - { - "fieldname": "from_bom_no", - "label": _("From BOM No"), - "fieldtype": "Link", - "options": "BOM", - "width": 150, - }, - { - "fieldname": "manufacturer", - "label": _("Manufacturer"), - "fieldtype": "Data", - "width": 120, - }, - { - "fieldname": "manufacturer_part_number", - "label": _("Manufacturer Part Number"), - "fieldtype": "Data", - "width": 150, - }, - { - "fieldname": "qty_per_unit", - "label": _("Qty Per Unit"), - "fieldtype": "Float", - "width": 110, - }, - { - "fieldname": "available_qty", - "label": _("Available Qty"), - "fieldtype": "Float", - "width": 120, - }, - { - "fieldname": "required_qty", - "label": _("Required Qty"), - "fieldtype": "Float", - "width": 120, - }, - { - "fieldname": "difference_qty", - "label": _("Difference Qty"), - "fieldtype": "Float", - "width": 130, - }, - { - "fieldname": "last_purchase_rate", - "label": _("Last Purchase Rate"), - "fieldtype": "Float", - "width": 160, - }, - ] - - -def get_bom_data(filters): - bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item" - - bom_item = frappe.qb.DocType(bom_item_table) - bin = frappe.qb.DocType("Bin") - - query = ( - frappe.qb.from_(bom_item) - .left_join(bin) - .on(bom_item.item_code == bin.item_code) - .select( - bom_item.item_code, - bom_item.description, - bom_item.parent.as_("from_bom_no"), - bom_item.qty_consumed_per_unit.as_("qty_per_unit"), - IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"), - ) - .where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM")) - .groupby(bom_item.item_code) - .orderby(bom_item.idx) - ) - - if filters.get("warehouse"): - warehouse_details = frappe.db.get_value( - "Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1 - ) - - if warehouse_details: - wh = frappe.qb.DocType("Warehouse") - query = query.where( - ExistsCriterion( - frappe.qb.from_(wh) - .select(wh.name) - .where( - (wh.lft >= warehouse_details.lft) - & (wh.rgt <= warehouse_details.rgt) - & (bin.warehouse == wh.name) - ) - ) - ) - else: - query = query.where(bin.warehouse == filters.get("warehouse")) - - if bom_item_table == "BOM Item": - query = query.select(bom_item.bom_no, bom_item.is_phantom_item) - - data = query.run(as_dict=True) - return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data - - -def explode_phantom_boms(data, filters): - original_bom = filters.get("bom") - replacements = [] - - for idx, item in enumerate(data): - if not item.is_phantom_item: - continue - - filters["bom"] = item.bom_no - children = get_bom_data(filters) - filters["bom"] = original_bom - - for child in children: - child.qty_per_unit = (child.qty_per_unit or 0) * (item.qty_per_unit or 0) - - replacements.append((idx, children)) - - for idx, children in reversed(replacements): - data.pop(idx) - data[idx:idx] = children - - filters["bom"] = original_bom - return data - - -def get_manufacturer_records(): - details = frappe.get_all( - "Item Manufacturer", fields=["manufacturer", "manufacturer_part_no", "item_code"] - ) - - manufacture_details = frappe._dict() - for detail in details: - dic = manufacture_details.setdefault(detail.get("item_code"), {}) - dic.setdefault("manufacturer", []).append(detail.get("manufacturer")) - dic.setdefault("manufacturer_part", []).append(detail.get("manufacturer_part_no")) - - return manufacture_details diff --git a/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py deleted file mode 100644 index 499629c2719..00000000000 --- a/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom -from erpnext.manufacturing.report.bom_stock_calculated.bom_stock_calculated import ( - execute as bom_stock_calculated_report, -) -from erpnext.stock.doctype.item.test_item import make_item -from erpnext.tests.utils import ERPNextTestSuite - - -class TestBOMStockCalculated(ERPNextTestSuite): - def setUp(self): - self.fg_item, self.rm_items = create_items() - self.boms = create_boms(self.fg_item, self.rm_items) - - def test_bom_stock_calculated(self): - qty_to_make = 10 - - # Case 1: When Item(s) Qty and Stock Qty are equal. - data = bom_stock_calculated_report( - filters={ - "qty_to_make": qty_to_make, - "bom": self.boms[0].name, - } - )[1] - expected_data = get_expected_data(self.boms[0], qty_to_make) - self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) - - # Case 2: When Item(s) Qty and Stock Qty are different and BOM Qty is 1. - data = bom_stock_calculated_report( - filters={ - "qty_to_make": qty_to_make, - "bom": self.boms[1].name, - } - )[1] - expected_data = get_expected_data(self.boms[1], qty_to_make) - self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) - - # Case 3: When Item(s) Qty and Stock Qty are different and BOM Qty is greater than 1. - data = bom_stock_calculated_report( - filters={ - "qty_to_make": qty_to_make, - "bom": self.boms[2].name, - } - )[1] - expected_data = get_expected_data(self.boms[2], qty_to_make) - self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) - - -def create_items(): - fg_item = make_item(properties={"is_stock_item": 1}).name - rm_item1 = make_item( - properties={ - "is_stock_item": 1, - "standard_rate": 100, - "opening_stock": 100, - "last_purchase_rate": 100, - "item_defaults": [{"company": "_Test Company", "default_warehouse": "Stores - _TC"}], - } - ).name - rm_item2 = make_item( - properties={ - "is_stock_item": 1, - "standard_rate": 200, - "opening_stock": 200, - "last_purchase_rate": 200, - "item_defaults": [{"company": "_Test Company", "default_warehouse": "Stores - _TC"}], - } - ).name - - return fg_item, [rm_item1, rm_item2] - - -def create_boms(fg_item, rm_items): - def update_bom_items(bom, uom, conversion_factor): - for item in bom.items: - item.uom = uom - item.conversion_factor = conversion_factor - - return bom - - bom1 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10) - - bom2 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10, do_not_submit=True) - bom2 = update_bom_items(bom2, "Box", 10) - bom2.save() - bom2.submit() - - bom3 = make_bom(item=fg_item, quantity=2, raw_materials=rm_items, rm_qty=10, do_not_submit=True) - bom3 = update_bom_items(bom3, "Box", 10) - bom3.save() - bom3.submit() - - return [bom1, bom2, bom3] - - -def get_expected_data(bom, qty_to_make): - expected_data = [] - - for idx in range(len(bom.items)): - expected_data.append( - [ - bom.items[idx].item_code, - bom.items[idx].item_code, - bom.name, - "", - "", - float(bom.items[idx].stock_qty / bom.quantity), - float(100 * (idx + 1)), - float(qty_to_make * (bom.items[idx].stock_qty / bom.quantity)), - float((100 * (idx + 1)) - (qty_to_make * (bom.items[idx].stock_qty / bom.quantity))), - float(100 * (idx + 1)), - ] - ) - - return expected_data diff --git a/erpnext/manufacturing/report/bom_stock_report/__init__.py b/erpnext/manufacturing/report/bom_stock_report/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.html b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.html deleted file mode 100644 index 2ae8848cc03..00000000000 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.html +++ /dev/null @@ -1,27 +0,0 @@ -

{%= __("BOM Stock Report") %}

-
{%= filters.bom %}
-
{%= filters.warehouse %}
-
- - - - - - - - - - - - - {% for(var i=0, l=data.length; i - - - - - - - {% } %} - -
{%= __("Item") %}{%= __("Description") %}{%= __("Required Qty") %}{%= __("In Stock Qty") %}{%= __("Enough Parts to Build") %}
{%= data[i][ __("Item")] %}{%= data[i][ __("Description")] %} {%= data[i][ __("Required Qty")] %} {%= data[i][ __("In Stock Qty")] %} {%= data[i][ __("Enough Parts to Build")] %}
diff --git a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js deleted file mode 100644 index 91d73d0101c..00000000000 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js +++ /dev/null @@ -1,41 +0,0 @@ -frappe.query_reports["BOM Stock Report"] = { - filters: [ - { - fieldname: "bom", - label: __("BOM"), - fieldtype: "Link", - options: "BOM", - reqd: 1, - }, - { - fieldname: "warehouse", - label: __("Warehouse"), - fieldtype: "Link", - options: "Warehouse", - reqd: 1, - }, - { - fieldname: "show_exploded_view", - label: __("Show exploded view"), - fieldtype: "Check", - }, - { - fieldname: "qty_to_produce", - label: __("Quantity to Produce"), - fieldtype: "Int", - default: "1", - }, - ], - formatter: function (value, row, column, data, default_formatter) { - value = default_formatter(value, row, column, data); - - if (column.id == "item") { - if (data["in_stock_qty"] >= data["required_qty"]) { - value = `${data["item"]}`; - } else { - value = `${data["item"]}`; - } - } - return value; - }, -}; diff --git a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.json b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.json deleted file mode 100644 index c563b87686d..00000000000 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "add_total_row": 0, - "apply_user_permissions": 1, - "creation": "2017-01-10 14:00:50.387244", - "disabled": 0, - "docstatus": 0, - "doctype": "Report", - "idx": 0, - "is_standard": "Yes", - "letter_head": "", - "modified": "2017-06-23 04:46:43.209008", - "modified_by": "Administrator", - "module": "Manufacturing", - "name": "BOM Stock Report", - "owner": "Administrator", - "query": "SELECT \n\tbom_item.item_code as \"Item:Link/Item:200\",\n\tbom_item.description as \"Description:Data:300\",\n\tbom_item.qty as \"Required Qty:Float:100\",\n\tledger.actual_qty as \"In Stock Qty:Float:100\",\n\tFLOOR(ledger.actual_qty /bom_item.qty) as \"Enough Parts to Build:Int:100\"\nFROM\n\t`tabBOM Item` AS bom_item \n\tLEFT JOIN `tabBin` AS ledger\t\n\t\tON bom_item.item_code = ledger.item_code \n\t\tAND ledger.warehouse = %(warehouse)s\nWHERE\n\tbom_item.parent=%(bom)s\n\nGROUP BY bom_item.item_code", - "ref_doctype": "BOM", - "report_name": "BOM Stock Report", - "report_type": "Script Report", - "roles": [ - { - "role": "Manufacturing Manager" - }, - { - "role": "Manufacturing User" - } - ] -} \ No newline at end of file diff --git a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py deleted file mode 100644 index eeda32c64c7..00000000000 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py +++ /dev/null @@ -1,106 +0,0 @@ -# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -import frappe -from frappe import _ -from frappe.query_builder.functions import Floor, Sum -from frappe.utils import cint - - -def execute(filters=None): - if not filters: - filters = {} - - columns = get_columns() - data = get_bom_stock(filters) - - return columns, data - - -def get_columns(): - return [ - _("Item") + ":Link/Item:150", - _("Item Name") + "::240", - _("Description") + "::300", - _("From BOM No") + "::200", - _("BOM Qty") + ":Float:160", - _("BOM UOM") + "::160", - _("Required Qty") + ":Float:120", - _("In Stock Qty") + ":Float:120", - _("Enough Parts to Build") + ":Float:200", - ] - - -def get_bom_stock(filters): - qty_to_produce = filters.get("qty_to_produce") - if cint(qty_to_produce) <= 0: - frappe.throw(_("Quantity to Produce should be greater than zero.")) - - bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item" - - warehouse = filters.get("warehouse") - warehouse_details = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"], as_dict=1) - - BOM = frappe.qb.DocType("BOM") - BOM_ITEM = frappe.qb.DocType(bom_item_table) - BIN = frappe.qb.DocType("Bin") - WH = frappe.qb.DocType("Warehouse") - - if warehouse_details: - bin_subquery = ( - frappe.qb.from_(BIN) - .join(WH) - .on(BIN.warehouse == WH.name) - .select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty")) - .where((WH.lft >= warehouse_details.lft) & (WH.rgt <= warehouse_details.rgt)) - .groupby(BIN.item_code) - ) - else: - bin_subquery = ( - frappe.qb.from_(BIN) - .select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty")) - .where(BIN.warehouse == warehouse) - .groupby(BIN.item_code) - ) - - QUERY = ( - frappe.qb.from_(BOM) - .join(BOM_ITEM) - .on(BOM.name == BOM_ITEM.parent) - .left_join(bin_subquery) - .on(BOM_ITEM.item_code == bin_subquery.item_code) - .select( - BOM_ITEM.item_code, - BOM_ITEM.item_name, - BOM_ITEM.description, - BOM.name, - Sum(BOM_ITEM.stock_qty), - BOM_ITEM.stock_uom, - (Sum(BOM_ITEM.stock_qty) * qty_to_produce) / BOM.quantity, - bin_subquery.actual_qty, - Floor(bin_subquery.actual_qty / ((Sum(BOM_ITEM.stock_qty) * qty_to_produce) / BOM.quantity)), - ) - .where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM")) - .groupby(BOM_ITEM.item_code) - .orderby(BOM_ITEM.idx) - ) - - if bom_item_table == "BOM Item": - QUERY = QUERY.select(BOM_ITEM.bom_no, BOM_ITEM.is_phantom_item) - - data = QUERY.run(as_list=True) - return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data - - -def explode_phantom_boms(data, filters): - expanded = [] - for row in data: - if row[-1]: # last element is `is_phantom_item` - phantom_filters = filters.copy() - phantom_filters["qty_to_produce"] = row[-5] - phantom_filters["bom"] = row[-2] - expanded.extend(get_bom_stock(phantom_filters)) - else: - expanded.append(row) - - return expanded diff --git a/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py b/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py deleted file mode 100644 index 43706fcb4de..00000000000 --- a/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py +++ /dev/null @@ -1,112 +0,0 @@ -# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -import frappe -from frappe.exceptions import ValidationError -from frappe.utils import floor - -from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom -from erpnext.manufacturing.report.bom_stock_report.bom_stock_report import ( - get_bom_stock as bom_stock_report, -) -from erpnext.stock.doctype.item.test_item import make_item -from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry -from erpnext.tests.utils import ERPNextTestSuite - - -class TestBomStockReport(ERPNextTestSuite): - def setUp(self): - self.warehouse = "_Test Warehouse - _TC" - self.fg_item, self.rm_items = create_items() - make_stock_entry(target=self.warehouse, item_code=self.rm_items[0], qty=20, basic_rate=100) - make_stock_entry(target=self.warehouse, item_code=self.rm_items[1], qty=40, basic_rate=200) - self.bom = make_bom(item=self.fg_item, quantity=1, raw_materials=self.rm_items, rm_qty=10) - - def test_bom_stock_report(self): - # Test 1: When `qty_to_produce` is 0. - filters = frappe._dict( - { - "bom": self.bom.name, - "warehouse": "Stores - _TC", - "qty_to_produce": 0, - } - ) - self.assertRaises(ValidationError, bom_stock_report, filters) - - # Test 2: When stock is not available. - data = bom_stock_report( - frappe._dict( - { - "bom": self.bom.name, - "warehouse": "Stores - _TC", - "qty_to_produce": 1, - } - ) - ) - expected_data = get_expected_data(self.bom, "Stores - _TC", 1) - self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) - - # Test 3: When stock is available. - data = bom_stock_report( - frappe._dict( - { - "bom": self.bom.name, - "warehouse": self.warehouse, - "qty_to_produce": 1, - } - ) - ) - expected_data = get_expected_data(self.bom, self.warehouse, 1) - self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) - - -def create_items(): - fg_item = make_item(properties={"is_stock_item": 1}).name - rm_item1 = make_item( - properties={ - "is_stock_item": 1, - "standard_rate": 100, - "opening_stock": 100, - "last_purchase_rate": 100, - } - ).name - rm_item2 = make_item( - properties={ - "is_stock_item": 1, - "standard_rate": 200, - "opening_stock": 200, - "last_purchase_rate": 200, - } - ).name - - return fg_item, [rm_item1, rm_item2] - - -def get_expected_data(bom, warehouse, qty_to_produce, show_exploded_view=False): - expected_data = [] - - for item in bom.get("exploded_items") if show_exploded_view else bom.get("items"): - in_stock_qty = frappe.get_cached_value( - "Bin", {"item_code": item.item_code, "warehouse": warehouse}, "actual_qty" - ) - - expected_data.append( - [ - item.item_code, - item.item_name, - item.description, - bom.name, - item.stock_qty, - item.stock_uom, - item.stock_qty * qty_to_produce / bom.quantity, - in_stock_qty, - floor(in_stock_qty / (item.stock_qty * qty_to_produce / bom.quantity)) - if in_stock_qty - else None, - item.bom_no, - item.is_phantom_item, - ] - ) - - return expected_data From 3a78af7f422f81fd64119ab73a59feb30e174e53 Mon Sep 17 00:00:00 2001 From: nishkagosalia Date: Thu, 26 Mar 2026 15:40:28 +0530 Subject: [PATCH 4/4] fix: test case --- .../report/bom_stock_analysis/bom_stock_analysis.js | 6 +++--- .../report/bom_stock_analysis/bom_stock_analysis.py | 5 +++-- erpnext/manufacturing/report/test_reports.py | 3 +-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js index 7c6ccfdf743..7629c102d7c 100644 --- a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js @@ -45,9 +45,9 @@ frappe.query_reports["BOM Stock Analysis"] = { } if (data && data.bold) { - if (column.fieldname === "description" || column.fieldname === "item_name") { - const qty_to_make = frappe.query_report.get_filter_value("qty_to_make"); - const producible = parseFloat(value) || 0; + if (column.fieldname === "description") { + const qty_to_make = Number(frappe.query_report.get_filter_value("qty_to_make")) || 0; + const producible = Number(String(data.description ?? "").replace(/,/g, "")) || 0; const colour = qty_to_make && producible < qty_to_make ? "red" : "green"; return `${value}`; } 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 78aa75aa7fa..59578127f9f 100644 --- a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py @@ -10,6 +10,7 @@ from pypika.terms import ExistsCriterion def execute(filters=None): + filters = filters or {} if filters.get("qty_to_make"): columns = get_columns_with_qty_to_make() data = get_data_with_qty_to_make(filters) @@ -35,7 +36,7 @@ def get_data_with_qty_to_make(filters): bom_data = get_bom_data(filters) manufacture_details = get_manufacturer_records() purchase_rates = batch_fetch_purchase_rates(bom_data) - qty_to_make = filters.get("qty_to_make") + qty_to_make = flt(filters.get("qty_to_make")) data = [] for row in bom_data: @@ -203,7 +204,7 @@ def get_bom_data(filters): bom_item.item_code, bom_item.description, bom_item.parent.as_("from_bom_no"), - bom_item.qty_consumed_per_unit.as_("qty_per_unit"), + Sum(bom_item.qty_consumed_per_unit).as_("qty_per_unit"), IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"), ) .where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM")) diff --git a/erpnext/manufacturing/report/test_reports.py b/erpnext/manufacturing/report/test_reports.py index 68f98769089..4a1471d8a24 100644 --- a/erpnext/manufacturing/report/test_reports.py +++ b/erpnext/manufacturing/report/test_reports.py @@ -19,8 +19,7 @@ class TestManufacturingReports(ERPNextTestSuite): self.REPORT_FILTER_TEST_CASES: list[tuple[ReportName, ReportFilters]] = [ ("BOM Explorer", {"bom": self.last_bom}), ("BOM Operations Time", {}), - ("BOM Stock Calculated", {"bom": self.last_bom, "qty_to_make": 2}), - ("BOM Stock Report", {"bom": self.last_bom, "qty_to_produce": 2}), + ("BOM Stock Analysis", {"bom": self.last_bom, "_optional": ["warehouse"]}), ("Cost of Poor Quality Report", {"item": "_Test Item", "serial_no": "00"}), ("Downtime Analysis", {}), (