From 3f983c9e4ddc09ae6556ef146342bb70c85e0588 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:06:50 +0000 Subject: [PATCH] feat: show non stock items and secondary items in work order (backport #55631) (#55636) Co-authored-by: Claude Opus 4.8 Co-authored-by: Mihir Kandoi --- .../doctype/work_order/test_work_order.py | 148 ++++++++++++++++++ .../doctype/work_order/work_order.js | 12 ++ .../doctype/work_order/work_order.json | 30 +++- .../doctype/work_order/work_order.py | 68 ++++++++ .../work_order_additional_item/__init__.py | 0 .../work_order_additional_item.json | 77 +++++++++ .../work_order_additional_item.py | 50 ++++++ 7 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 erpnext/manufacturing/doctype/work_order_additional_item/__init__.py create mode 100644 erpnext/manufacturing/doctype/work_order_additional_item/work_order_additional_item.json create mode 100644 erpnext/manufacturing/doctype/work_order_additional_item/work_order_additional_item.py diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 0c595df283d..78df227fa9e 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -4302,6 +4302,154 @@ class TestWorkOrder(ERPNextTestSuite): if row.s_warehouse: self.assertIn(row.item_code, [raw_material_1, raw_material_2]) + def test_non_stock_items_shown_in_work_order(self): + """Non stock, non phantom raw materials should appear in non_stock_items with scaled qty & amount.""" + fg_item = make_item("_Test WO Non Stock FG", {"is_stock_item": 1}).name + stock_rm = make_item( + "_Test WO Non Stock - Stock RM", {"is_stock_item": 1, "valuation_rate": 100} + ).name + non_stock_rm = make_item( + "_Test WO Non Stock - Non Stock RM", {"is_stock_item": 0, "valuation_rate": 7} + ).name + + bom = frappe.get_doc( + { + "doctype": "BOM", + "item": fg_item, + "currency": "INR", + "quantity": 8, + "company": "_Test Company", + } + ) + bom.append("items", {"item_code": stock_rm, "qty": 5}) + bom.append("items", {"item_code": non_stock_rm, "qty": 3}) + bom.insert() + bom.submit() + + wo_order = make_wo_order_test_record( + production_item=fg_item, bom_no=bom.name, qty=20, skip_transfer=1, do_not_save=True + ) + + non_stock_items = wo_order.non_stock_items + # only the non stock, non phantom item is shown; the stock item is excluded + self.assertEqual(len(non_stock_items), 1) + row = non_stock_items[0] + self.assertEqual(row.item_code, non_stock_rm) + # qty = (bom_item_qty / bom_qty) * wo_qty = (3 / 8) * 20 = 7.5 + self.assertEqual(flt(row.qty, 6), 7.5) + # amount = base_rate * qty = 7 * 7.5 = 52.5 + self.assertEqual(flt(row.amount, 6), 52.5) + + def test_secondary_items_from_bom_without_manufacture_entry(self): + """Without any manufacture entry, secondary items are derived from the BOM with scaled qty & amount.""" + fg_item = make_item("_Test WO Sec BOM FG", {"is_stock_item": 1}).name + stock_rm = make_item("_Test WO Sec BOM RM", {"is_stock_item": 1, "valuation_rate": 100}).name + scrap_item = make_item("_Test WO Sec BOM Scrap", {"is_stock_item": 1, "valuation_rate": 0}).name + + bom = frappe.get_doc( + { + "doctype": "BOM", + "item": fg_item, + "currency": "INR", + "quantity": 8, + "company": "_Test Company", + } + ) + bom.append("items", {"item_code": stock_rm, "qty": 2}) + bom.append( + "secondary_items", + { + "type": "Scrap", + "item_code": scrap_item, + "item_name": scrap_item, + "qty": 3, + "cost_allocation_per": 25, + "process_loss_per": 0, + }, + ) + bom.insert() + bom.submit() + # cost = raw_material_cost * (cost_allocation_per / 100) = 200 * 0.25 = 50 + self.assertEqual(flt(bom.secondary_items[0].cost, 6), 50.0) + + wo_order = make_wo_order_test_record( + production_item=fg_item, bom_no=bom.name, qty=20, skip_transfer=1 + ) + + secondary_items = wo_order.secondary_items + self.assertEqual(len(secondary_items), 1) + row = secondary_items[0] + self.assertEqual(row.item_code, scrap_item) + self.assertEqual(row.type, "Scrap") + # data is fetched from the BOM (carries bom_qty) + self.assertEqual(flt(row.bom_qty), 8.0) + # qty = (bom_secondary_qty / bom_qty) * wo_qty = (3 / 8) * 20 = 7.5 + self.assertEqual(flt(row.qty, 6), 7.5) + # amount = cost * qty = 50 * 7.5 = 375 + self.assertEqual(flt(row.amount, 6), 375.0) + + def test_secondary_items_reflect_manufacture_entry(self): + """Once a manufacture entry exists, secondary items reflect what was generated, not the BOM.""" + fg_item = make_item("_Test WO Sec SE FG", {"is_stock_item": 1}).name + stock_rm = make_item("_Test WO Sec SE RM", {"is_stock_item": 1, "valuation_rate": 100}).name + scrap_item = make_item("_Test WO Sec SE Scrap", {"is_stock_item": 1, "valuation_rate": 0}).name + + bom = frappe.get_doc( + { + "doctype": "BOM", + "item": fg_item, + "currency": "INR", + "quantity": 8, + "company": "_Test Company", + } + ) + bom.append("items", {"item_code": stock_rm, "qty": 2}) + bom.append( + "secondary_items", + { + "type": "Scrap", + "item_code": scrap_item, + "item_name": scrap_item, + "qty": 3, + "cost_allocation_per": 25, + "process_loss_per": 0, + }, + ) + bom.insert() + bom.submit() + + wo_order = make_wo_order_test_record( + production_item=fg_item, + bom_no=bom.name, + qty=20, + skip_transfer=1, + source_warehouse="_Test Warehouse - _TC", + ) + + # before any manufacture entry, data comes from the BOM + self.assertEqual(flt(wo_order.secondary_items[0].qty, 6), 7.5) + + # make raw material available and manufacture a partial quantity + test_stock_entry.make_stock_entry( + item_code=stock_rm, target="_Test Warehouse - _TC", qty=100, basic_rate=100 + ) + manufacture_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 8)) + manufacture_entry.submit() + + generated_row = next(row for row in manufacture_entry.items if row.type == "Scrap") + + wo_order.reload() + secondary_items = wo_order.secondary_items + self.assertEqual(len(secondary_items), 1) + row = secondary_items[0] + # now sourced from the manufacture entry, not the BOM + self.assertIsNone(row.get("bom_qty")) + self.assertEqual(row.item_code, scrap_item) + self.assertEqual(flt(row.qty, 6), flt(generated_row.qty, 6)) + self.assertEqual(flt(row.amount, 6), flt(generated_row.amount, 6)) + # generated qty (3.0 for 8 units) differs from the BOM-scaled qty (7.5 for 20 units) + self.assertEqual(flt(row.qty, 6), 3.0) + def get_reserved_entries(voucher_no, warehouse=None): doctype = frappe.qb.DocType("Stock Reservation Entry") diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 5131b30c889..24362a4b6f0 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -87,6 +87,9 @@ frappe.ui.form.on("Work Order", { frm.set_indicator_formatter("operation", function (doc) { return frm.doc.qty == doc.completed_qty ? "green" : "orange"; }); + + frm.fields_dict["non_stock_items"].grid.set_column_disp_in_list_view("type", false); + frm.fields_dict["secondary_items"].grid.set_column_disp_in_list_view("rate", false); }, set_company_filters(frm, fieldname) { @@ -127,6 +130,15 @@ frappe.ui.form.on("Work Order", { } }, + onload_post_render(frm) { + const label = frm.doc.__onload?.secondary_items_generated + ? __("Secondary Items (as per Manufacture Entries)") + : __("Secondary Items (as per BOM)"); + + frm.set_df_property("secondary_items", "label", label); + frm.fields_dict["secondary_items"].grid.wrapper?.find("> .control-label").text(label); + }, + source_warehouse: function (frm) { let transaction_controller = new erpnext.TransactionController(); transaction_controller.autofill_warehouse( diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 9dc57a3b99c..04b970be3e1 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -71,6 +71,10 @@ "has_batch_no", "column_break_18", "batch_size", + "additional_costs_section", + "non_stock_items", + "secondary_items_section", + "secondary_items", "reference_section", "project", "subcontracting_inward_order", @@ -703,6 +707,30 @@ "fieldname": "production_item_info_section", "fieldtype": "Section Break", "label": "Production Item Info" + }, + { + "fieldname": "additional_costs_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "non_stock_items", + "fieldtype": "Table", + "is_virtual": 1, + "label": "Additional Costs (as per BOM)", + "options": "Work Order Additional Item", + "read_only": 1 + }, + { + "fieldname": "secondary_items_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "secondary_items", + "fieldtype": "Table", + "is_virtual": 1, + "label": "Secondary Items (as per BOM)", + "options": "Work Order Additional Item", + "read_only": 1 } ], "grid_page_length": 50, @@ -711,7 +739,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2026-05-19 12:20:38.102403", + "modified": "2026-06-03 21:35:34.175667", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 40f3152189c..47223b32a5d 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -76,6 +76,9 @@ class WorkOrder(Document): if TYPE_CHECKING: from frappe.types import DF + from erpnext.manufacturing.doctype.work_order_additional_item.work_order_additional_item import ( + WorkOrderAdditionalItem, + ) from erpnext.manufacturing.doctype.work_order_item.work_order_item import WorkOrderItem from erpnext.manufacturing.doctype.work_order_operation.work_order_operation import WorkOrderOperation @@ -106,6 +109,7 @@ class WorkOrder(Document): max_producible_qty: DF.Float mps: DF.Link | None naming_series: DF.Literal["MFG-WO-.YYYY.-"] + non_stock_items: DF.Table[WorkOrderAdditionalItem] operations: DF.Table[WorkOrderOperation] planned_end_date: DF.Datetime | None planned_operating_cost: DF.Currency @@ -124,6 +128,7 @@ class WorkOrder(Document): sales_order: DF.Link | None sales_order_item: DF.Data | None scrap_warehouse: DF.Link | None + secondary_items: DF.Table[WorkOrderAdditionalItem] skip_transfer: DF.Check source_warehouse: DF.Link | None status: DF.Literal[ @@ -167,6 +172,69 @@ class WorkOrder(Document): if based_on := frappe.get_cached_value("BOM", self.bom_no, "backflush_based_on"): self.set_onload("backflush_raw_materials_based_on", based_on) + @property + def secondary_items(self): + parent = frappe.qb.DocType("Stock Entry") + child = frappe.qb.DocType("Stock Entry Detail") + secondary_items_generated = ( + frappe.qb.from_(parent) + .join(child) + .on(parent.name == child.parent) + .where( + (parent.work_order == self.name) + & (parent.docstatus == 1) + & ((child.type != "") | (child.is_legacy_scrap_item == 1)) + ) + .select( + child.item_code, + Case().when(child.is_legacy_scrap_item == 1, "Scrap (Legacy)").else_(child.type).as_("type"), + child.qty, + child.uom, + child.amount, + ) + .run(as_dict=True) + ) + if secondary_items_generated: + self.set_onload("secondary_items_generated", True) + return secondary_items_generated + else: + secondary_items = frappe.get_query( + "BOM", + filters={"name": self.bom_no}, + fields=[ + "secondary_items.item_code", + "secondary_items.type", + "secondary_items.qty", + "secondary_items.uom", + "secondary_items.cost as amount", + "quantity as bom_qty", + ], + ).run(as_dict=True) + secondary_items = [item for item in secondary_items if item.item_code] + for item in secondary_items: + item["qty"] = (item.qty / item.bom_qty) * self.qty + item["amount"] = flt(item.amount) * item.qty + return secondary_items + + @property + def non_stock_items(self): + non_stock_items = frappe.get_query( + "BOM", + filters={"name": self.bom_no, "items.is_stock_item": 0, "items.is_phantom_item": 0}, + fields=[ + "items.item_code", + "items.qty", + "items.uom", + "items.base_rate as rate", + "items.base_amount as amount", + "quantity as bom_qty", + ], + ).run(as_dict=True) + for item in non_stock_items: + item["qty"] = (item.qty / item.bom_qty) * self.qty + item["amount"] = item.rate * item["qty"] + return non_stock_items + def show_create_job_card_button(self): jc_doctype = frappe.qb.DocType("Job Card") query = ( diff --git a/erpnext/manufacturing/doctype/work_order_additional_item/__init__.py b/erpnext/manufacturing/doctype/work_order_additional_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/doctype/work_order_additional_item/work_order_additional_item.json b/erpnext/manufacturing/doctype/work_order_additional_item/work_order_additional_item.json new file mode 100644 index 00000000000..e4fd72bbb37 --- /dev/null +++ b/erpnext/manufacturing/doctype/work_order_additional_item/work_order_additional_item.json @@ -0,0 +1,77 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-06-03 15:52:39.829793", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "type", + "qty", + "uom", + "column_break_lrsc", + "rate", + "amount" + ], + "fields": [ + { + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item" + }, + { + "fieldname": "type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Type" + }, + { + "fieldname": "column_break_lrsc", + "fieldtype": "Column Break" + }, + { + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Qty" + }, + { + "fieldname": "uom", + "fieldtype": "Link", + "in_list_view": 1, + "label": "UOM", + "options": "UOM" + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount" + }, + { + "fieldname": "rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Rate" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "is_virtual": 1, + "istable": 1, + "links": [], + "modified": "2026-06-03 21:46:46.738564", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Work Order Additional Item", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/manufacturing/doctype/work_order_additional_item/work_order_additional_item.py b/erpnext/manufacturing/doctype/work_order_additional_item/work_order_additional_item.py new file mode 100644 index 00000000000..4fb2876a77e --- /dev/null +++ b/erpnext/manufacturing/doctype/work_order_additional_item/work_order_additional_item.py @@ -0,0 +1,50 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class WorkOrderAdditionalItem(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + amount: DF.Currency + item_code: DF.Link | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + qty: DF.Float + rate: DF.Currency + type: DF.Data | None + uom: DF.Link | None + # end: auto-generated types + + @staticmethod + def get_list(self, *args, **kwargs): + pass + + @staticmethod + def get_count(self, *args, **kwargs): + pass + + @staticmethod + def get_stats(self, *args, **kwargs): + pass + + def db_insert(self, *args, **kwargs): + pass + + def load_from_db(self, *args, **kwargs): + pass + + def db_update(self, *args, **kwargs): + pass + + def delete(self, *args, **kwargs): + pass