Skip to content

Commit 8a5e2cc

Browse files
nishkagosaliamergify[bot]
authored andcommitted
feat: Bom stock analysis report
(cherry picked from commit 5d08835)
1 parent d723751 commit 8a5e2cc

5 files changed

Lines changed: 475 additions & 0 deletions

File tree

erpnext/manufacturing/report/bom_stock_analysis/__init__.py

Whitespace-only changes.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
2+
// For license information, please see license.txt
3+
4+
frappe.query_reports["BOM Stock Analysis"] = {
5+
filters: [
6+
{
7+
fieldname: "bom",
8+
label: __("BOM"),
9+
fieldtype: "Link",
10+
options: "BOM",
11+
reqd: 1,
12+
},
13+
{
14+
fieldname: "warehouse",
15+
label: __("Warehouse"),
16+
fieldtype: "Link",
17+
options: "Warehouse",
18+
},
19+
{
20+
fieldname: "qty_to_make",
21+
label: __("FG Items to Make"),
22+
fieldtype: "Float",
23+
},
24+
{
25+
fieldname: "show_exploded_view",
26+
label: __("Show availability of exploded items"),
27+
fieldtype: "Check",
28+
default: false,
29+
},
30+
],
31+
formatter: function (value, row, column, data, default_formatter) {
32+
value = default_formatter(value, row, column, data);
33+
34+
if (column.id == "producible_fg_item") {
35+
if (data["producible_fg_item"] >= data["required_qty"]) {
36+
value = `<a style='color:green' href="/app/item/${data["producible_fg_item"]}" data-doctype="producible_fg_item">${data["producible_fg_item"]}</a>`;
37+
} else {
38+
value = `<a style='color:red' href="/app/item/${data["producible_fg_item"]}" data-doctype="producible_fg_item">${data["producible_fg_item"]}</a>`;
39+
}
40+
}
41+
return value;
42+
},
43+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"add_total_row": 0,
3+
"add_translate_data": 0,
4+
"columns": [],
5+
"creation": "2026-03-23 15:42:06.064606",
6+
"disabled": 0,
7+
"docstatus": 0,
8+
"doctype": "Report",
9+
"filters": [],
10+
"idx": 0,
11+
"is_standard": "Yes",
12+
"letter_head": null,
13+
"modified": "2026-03-23 15:48:56.933892",
14+
"modified_by": "Administrator",
15+
"module": "Manufacturing",
16+
"name": "BOM Stock Analysis",
17+
"owner": "Administrator",
18+
"prepared_report": 0,
19+
"ref_doctype": "BOM",
20+
"report_name": "BOM Stock Analysis",
21+
"report_type": "Script Report",
22+
"roles": [
23+
{
24+
"role": "Manufacturing Manager"
25+
},
26+
{
27+
"role": "Manufacturing User"
28+
}
29+
],
30+
"timeout": 0
31+
}
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
2+
# For license information, please see license.txt
3+
4+
import frappe
5+
from frappe import _
6+
from frappe.query_builder.functions import Floor, IfNull, Sum
7+
from frappe.utils.data import comma_and
8+
from pypika.terms import ExistsCriterion
9+
10+
11+
def execute(filters=None):
12+
qty_to_make = filters.get("qty_to_make")
13+
14+
if qty_to_make:
15+
columns = get_columns_with_qty_to_make()
16+
data = get_data_with_qty_to_make(filters)
17+
return columns, data
18+
else:
19+
data = []
20+
columns = get_columns_without_qty_to_make()
21+
bom_data = get_producible_fg_items(filters)
22+
for row in bom_data:
23+
data.append(row)
24+
25+
return columns, data
26+
27+
28+
def get_data_with_qty_to_make(filters):
29+
data = []
30+
bom_data = get_bom_data(filters)
31+
manufacture_details = get_manufacturer_records()
32+
33+
for row in bom_data:
34+
required_qty = filters.get("qty_to_make") * row.qty_per_unit
35+
last_purchase_rate = frappe.db.get_value("Item", row.item_code, "last_purchase_rate")
36+
37+
data.append(get_report_data(last_purchase_rate, required_qty, row, manufacture_details))
38+
39+
return data
40+
41+
42+
def get_report_data(last_purchase_rate, required_qty, row, manufacture_details):
43+
qty_per_unit = row.qty_per_unit if row.qty_per_unit > 0 else 0
44+
difference_qty = row.actual_qty - required_qty
45+
return [
46+
row.item_code,
47+
row.description,
48+
row.from_bom_no,
49+
comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer", []), add_quotes=False),
50+
comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer_part", []), add_quotes=False),
51+
qty_per_unit,
52+
row.actual_qty,
53+
required_qty,
54+
difference_qty,
55+
last_purchase_rate,
56+
row.actual_qty // qty_per_unit if qty_per_unit else 0,
57+
]
58+
59+
60+
def get_columns_with_qty_to_make():
61+
return [
62+
{
63+
"fieldname": "item",
64+
"label": _("Item"),
65+
"fieldtype": "Link",
66+
"options": "Item",
67+
"width": 120,
68+
},
69+
{
70+
"fieldname": "description",
71+
"label": _("Description"),
72+
"fieldtype": "Data",
73+
"width": 150,
74+
},
75+
{
76+
"fieldname": "from_bom_no",
77+
"label": _("From BOM No"),
78+
"fieldtype": "Link",
79+
"options": "BOM",
80+
"width": 150,
81+
},
82+
{
83+
"fieldname": "manufacturer",
84+
"label": _("Manufacturer"),
85+
"fieldtype": "Data",
86+
"width": 120,
87+
},
88+
{
89+
"fieldname": "manufacturer_part_number",
90+
"label": _("Manufacturer Part Number"),
91+
"fieldtype": "Data",
92+
"width": 150,
93+
},
94+
{
95+
"fieldname": "qty_per_unit",
96+
"label": _("Qty Per Unit"),
97+
"fieldtype": "Float",
98+
"width": 110,
99+
},
100+
{
101+
"fieldname": "available_qty",
102+
"label": _("Available Qty"),
103+
"fieldtype": "Float",
104+
"width": 120,
105+
},
106+
{
107+
"fieldname": "required_qty",
108+
"label": _("Required Qty"),
109+
"fieldtype": "Float",
110+
"width": 120,
111+
},
112+
{
113+
"fieldname": "difference_qty",
114+
"label": _("Difference Qty"),
115+
"fieldtype": "Float",
116+
"width": 130,
117+
},
118+
{
119+
"fieldname": "last_purchase_rate",
120+
"label": _("Last Purchase Rate"),
121+
"fieldtype": "Float",
122+
"width": 160,
123+
},
124+
{
125+
"fieldname": "producible_fg_item",
126+
"label": _("Producible FG Item"),
127+
"fieldtype": "Float",
128+
"width": 200,
129+
},
130+
]
131+
132+
133+
def get_columns_without_qty_to_make():
134+
return [
135+
_("Item") + ":Link/Item:150",
136+
_("Item Name") + "::240",
137+
_("Description") + "::300",
138+
_("From BOM No") + "::200",
139+
_("Required Qty") + ":Float:160",
140+
_("Producible FG Item") + ":Float:200",
141+
]
142+
143+
144+
def get_bom_data(filters):
145+
bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item"
146+
147+
bom_item = frappe.qb.DocType(bom_item_table)
148+
bin = frappe.qb.DocType("Bin")
149+
150+
query = (
151+
frappe.qb.from_(bom_item)
152+
.left_join(bin)
153+
.on(bom_item.item_code == bin.item_code)
154+
.select(
155+
bom_item.item_code,
156+
bom_item.description,
157+
bom_item.parent.as_("from_bom_no"),
158+
bom_item.qty_consumed_per_unit.as_("qty_per_unit"),
159+
IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"),
160+
)
161+
.where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM"))
162+
.groupby(bom_item.item_code)
163+
.orderby(bom_item.idx)
164+
)
165+
166+
if filters.get("warehouse"):
167+
warehouse_details = frappe.db.get_value(
168+
"Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1
169+
)
170+
171+
if warehouse_details:
172+
wh = frappe.qb.DocType("Warehouse")
173+
query = query.where(
174+
ExistsCriterion(
175+
frappe.qb.from_(wh)
176+
.select(wh.name)
177+
.where(
178+
(wh.lft >= warehouse_details.lft)
179+
& (wh.rgt <= warehouse_details.rgt)
180+
& (bin.warehouse == wh.name)
181+
)
182+
)
183+
)
184+
else:
185+
query = query.where(bin.warehouse == filters.get("warehouse"))
186+
187+
if bom_item_table == "BOM Item":
188+
query = query.select(bom_item.bom_no, bom_item.is_phantom_item)
189+
190+
data = query.run(as_dict=True)
191+
return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data
192+
193+
194+
def explode_phantom_boms(data, filters):
195+
original_bom = filters.get("bom")
196+
replacements = []
197+
198+
for idx, item in enumerate(data):
199+
if not item.is_phantom_item:
200+
continue
201+
202+
filters["bom"] = item.bom_no
203+
children = get_bom_data(filters)
204+
filters["bom"] = original_bom
205+
206+
for child in children:
207+
child.qty_per_unit = (child.qty_per_unit or 0) * (item.qty_per_unit or 0)
208+
209+
replacements.append((idx, children))
210+
211+
for idx, children in reversed(replacements):
212+
data.pop(idx)
213+
data[idx:idx] = children
214+
215+
filters["bom"] = original_bom
216+
return data
217+
218+
219+
def get_manufacturer_records():
220+
details = frappe.get_all(
221+
"Item Manufacturer", fields=["manufacturer", "manufacturer_part_no", "item_code"]
222+
)
223+
224+
manufacture_details = frappe._dict()
225+
for detail in details:
226+
dic = manufacture_details.setdefault(detail.get("item_code"), {})
227+
dic.setdefault("manufacturer", []).append(detail.get("manufacturer"))
228+
dic.setdefault("manufacturer_part", []).append(detail.get("manufacturer_part_no"))
229+
230+
return manufacture_details
231+
232+
233+
def get_producible_fg_items(filters):
234+
BOM_ITEM = frappe.qb.DocType("BOM Item")
235+
BOM = frappe.qb.DocType("BOM")
236+
BIN = frappe.qb.DocType("Bin")
237+
WH = frappe.qb.DocType("Warehouse")
238+
239+
warehouse = filters.get("warehouse")
240+
warehouse_details = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"], as_dict=1)
241+
242+
if not warehouse:
243+
frappe.throw(_("Warehouse is required to get producible FG Items"))
244+
245+
if warehouse_details:
246+
bin_subquery = (
247+
frappe.qb.from_(BIN)
248+
.join(WH)
249+
.on(BIN.warehouse == WH.name)
250+
.select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty"))
251+
.where((WH.lft >= warehouse_details.lft) & (WH.rgt <= warehouse_details.rgt))
252+
.groupby(BIN.item_code)
253+
)
254+
else:
255+
bin_subquery = (
256+
frappe.qb.from_(BIN)
257+
.select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty"))
258+
.where(BIN.warehouse == warehouse)
259+
.groupby(BIN.item_code)
260+
)
261+
262+
query = (
263+
frappe.qb.from_(BOM_ITEM)
264+
.join(BOM)
265+
.on(BOM_ITEM.parent == BOM.name)
266+
.left_join(bin_subquery)
267+
.on(BOM_ITEM.item_code == bin_subquery.item_code)
268+
.select(
269+
BOM_ITEM.item_code,
270+
BOM_ITEM.item_name,
271+
BOM_ITEM.description,
272+
BOM_ITEM.parent.as_("from_bom_no"),
273+
(BOM_ITEM.stock_qty / BOM.quantity).as_("qty_per_unit"),
274+
Floor(bin_subquery.actual_qty / ((Sum(BOM_ITEM.stock_qty)) / BOM.quantity)),
275+
)
276+
.where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM"))
277+
.groupby(BOM_ITEM.item_code)
278+
.orderby(BOM_ITEM.idx)
279+
)
280+
281+
data = query.run(as_list=True)
282+
return data

0 commit comments

Comments
 (0)