From 144f92d58e15c6aab4aefefbaf2cfd9b016d523c Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Thu, 9 Jul 2026 19:43:01 +0530 Subject: [PATCH] feat(manufacturing): create material request for raw materials from work order (#56980) * feat(manufacturing): create material request for raw materials from work order * test(manufacturing): cover work order material request flow --- .../doctype/work_order/test_work_order.py | 60 ++++++++++++ .../doctype/work_order/work_order.js | 11 +++ .../doctype/work_order/work_order.py | 91 ++++++++++++++++++- .../material_request/material_request.py | 17 +++- 4 files changed, 177 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 57d44084fd0..afa997efef5 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -18,6 +18,7 @@ from erpnext.manufacturing.doctype.work_order.work_order import ( StockOverProductionError, close_work_order, make_job_card, + make_material_request, make_stock_entry, make_stock_return_entry, stop_unstop, @@ -1547,6 +1548,65 @@ class TestWorkOrder(ERPNextTestSuite): work_order.reload() self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0) + def test_work_order_material_request_and_bom_details(self): + from erpnext.stock.doctype.material_request.material_request import ( + make_stock_entry as mr_to_stock_entry, + ) + + work_order = make_wo_order_test_record( + planned_start_date=now(), qty=2, source_warehouse="Stores - _TC" + ) + + mr = make_material_request(work_order.name) + mr.schedule_date = today() + for item in mr.items: + item.schedule_date = today() + mr.submit() + self.assertEqual(mr.work_order, work_order.name) + + ste = mr_to_stock_entry(mr.name) + self.assertEqual(ste.purpose, "Material Transfer for Manufacture") + self.assertEqual(ste.work_order, work_order.name) + self.assertEqual(ste.from_bom, 1.0) + self.assertEqual(ste.bom_no, work_order.bom_no) + self.assertEqual(ste.fg_completed_qty, 0.0) + + def test_status_in_process_when_only_one_required_item_transferred_via_material_request(self): + """Same bottleneck scenario as the Pick List flow, but the intermediate document is a + Material Request created directly from the Work Order: min-fraction keeps + material_transferred_for_manufacturing at 0, but the work order must still move to + In Process because material is already in WIP. + """ + from erpnext.stock.doctype.material_request.material_request import ( + make_stock_entry as mr_to_stock_entry, + ) + + work_order = make_wo_order_test_record( + planned_start_date=now(), qty=2, source_warehouse="Stores - _TC" + ) + test_stock_entry.make_stock_entry( + item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0 + ) + test_stock_entry.make_stock_entry( + item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0 + ) + + mr = make_material_request(work_order.name) + mr.schedule_date = today() + # request only _Test Item; the other required item is left off this material request + mr.items = [item for item in mr.items if item.item_code == "_Test Item"] + for item in mr.items: + item.schedule_date = today() + mr.submit() + + stock_entry = frappe.get_doc(mr_to_stock_entry(mr.name)) + self.assertEqual(stock_entry.fg_completed_qty, 0.0) + stock_entry.submit() + + work_order.reload() + self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0) + self.assertEqual(work_order.status, "In Process") + def test_backflushed_batch_raw_materials_based_on_transferred(self): frappe.db.set_single_value( "Manufacturing Settings", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index ef2365ea4fd..04f259f1508 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -813,6 +813,10 @@ erpnext.work_order = { erpnext.work_order.create_pick_list(frm); }); + frm.add_custom_button(__("Material Request"), function () { + erpnext.work_order.make_material_request(frm); + }); + var start_btn = frm.add_custom_button(__("Start"), function () { erpnext.work_order.make_se(frm, "Material Transfer for Manufacture"); }); @@ -1151,6 +1155,13 @@ erpnext.work_order = { } }, + make_material_request: function (frm) { + frappe.model.open_mapped_doc({ + method: "erpnext.manufacturing.doctype.work_order.work_order.make_material_request", + frm, + }); + }, + create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") { const max = this.get_max_transferable_qty(frm, purpose); diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 5de2594b146..3f470ccd216 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -677,7 +677,11 @@ class WorkOrder(Document): elif self.docstatus == 1: if status not in ["Closed", "Stopped"]: status = "Not Started" - if flt(self.material_transferred_for_manufacturing) > 0 or self.skip_transfer: + if ( + flt(self.material_transferred_for_manufacturing) > 0 + or self.skip_transfer + or self._has_transferred_material() + ): status = "In Process" precision = frappe.get_precision("Work Order", "produced_qty") @@ -711,6 +715,57 @@ class WorkOrder(Document): return status + def _has_transferred_material(self): + """True if any raw material transferred against this work order via a pick list or a + material request is still, net of returns, in WIP (these leave + material_transferred_for_manufacturing at 0 via the min-fraction rule).""" + ste = frappe.qb.DocType("Stock Entry") + ste_child = frappe.qb.DocType("Stock Entry Detail") + mr_ste = frappe.qb.DocType("Stock Entry") + mr_child = frappe.qb.DocType("Stock Entry Detail") + # Stock Entry only carries `material_request` at the child-row level, so a Stock + # Entry is "MR-sourced" if *any* of its rows link back to a Material Request against + # this work order; the join to mr_ste keeps this scoped to this work order's entries + # instead of scanning every Material-Request-linked row in the system. + mr_sourced_stock_entries = ( + frappe.qb.from_(mr_child) + .inner_join(mr_ste) + .on(mr_ste.name == mr_child.parent) + .select(mr_child.parent) + .where( + (mr_child.material_request.isnotnull()) + & (mr_ste.work_order == self.name) + & (mr_ste.docstatus == 1) + & (mr_ste.purpose == "Material Transfer for Manufacture") + ) + ) + common_filters = ( + (ste.work_order == self.name) + & (ste.docstatus == 1) + & (ste.purpose == "Material Transfer for Manufacture") + ) + transferred_qty = ( + frappe.qb.from_(ste) + .inner_join(ste_child) + .on(ste_child.parent == ste.name) + .select(Sum(ste_child.transfer_qty)) + .where( + common_filters + & (ste.is_return == 0) + & (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries)) + ) + ).run()[0][0] + # Returns don't carry their own pick_list/material_request reference, so net every + # return against this work order to correctly clear WIP after a full return. + returned_qty = ( + frappe.qb.from_(ste) + .inner_join(ste_child) + .on(ste_child.parent == ste.name) + .select(Sum(ste_child.transfer_qty)) + .where(common_filters & (ste.is_return == 1)) + ).run()[0][0] + return flt(transferred_qty) - flt(returned_qty) > 0 + def update_work_order_qty(self): """Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order based on Stock Entry""" @@ -3014,6 +3069,40 @@ def get_reserved_qty_for_production( return query.run()[0][0] or 0.0 +@frappe.whitelist() +def make_material_request(source_name: str, target_doc: str | dict | None = None): + frappe.has_permission("Material Request", "create", throw=True) + + doc = get_mapped_doc("Work Order", source_name, _material_request_mapping(), target_doc) + doc.material_request_type = "Material Transfer" + return doc + + +def _material_request_mapping(): + return { + "Work Order": { + "doctype": "Material Request", + "validation": {"docstatus": ["=", 1]}, + "field_map": {"name": "work_order"}, + }, + "Work Order Item": { + "doctype": "Material Request Item", + "field_map": [ + ("stock_uom", "uom"), + ("source_warehouse", "from_warehouse"), + ], + "postprocess": _set_material_request_item, + "condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty), + }, + } + + +def _set_material_request_item(source, target, source_parent): + target.warehouse = source_parent.wip_warehouse + target.qty = flt(source.required_qty) - flt(source.transferred_qty) + target.schedule_date = nowdate() + + @frappe.whitelist() def make_stock_return_entry(work_order): from erpnext.stock.doctype.stock_entry.stock_entry import get_available_materials diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index f9cd772bb14..5ed37f34203 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -717,7 +717,7 @@ def make_supplier_quotation(source_name, target_doc=None): @frappe.whitelist() -def make_stock_entry(source_name, target_doc=None): +def make_stock_entry(source_name: str, target_doc: str | dict | None = None): def update_item(obj, target, source_parent): qty = ( flt(flt(obj.stock_qty) - flt(obj.ordered_qty)) / target.conversion_factor @@ -753,6 +753,9 @@ def make_stock_entry(source_name, target_doc=None): if source.job_card: target.purpose = "Material Transfer for Manufacture" + if source.work_order: + target.purpose = "Material Transfer for Manufacture" + if source.material_request_type == "Customer Provided": target.purpose = "Material Receipt" @@ -772,6 +775,18 @@ def make_stock_entry(source_name, target_doc=None): target.fg_completed_qty = job_card_details[0].for_quantity target.from_bom = 1 + if source.work_order: + work_order_details = frappe.db.get_value( + "Work Order", source.work_order, ["bom_no", "use_multi_level_bom"], as_dict=True + ) + + if work_order_details: + target.bom_no = work_order_details.bom_no + target.use_multi_level_bom = work_order_details.use_multi_level_bom + target.from_bom = 1 + # not fg-qty-driven, mirrors the Pick List -> Stock Entry transfer for this Work Order + target.fg_completed_qty = 0 + doclist = get_mapped_doc( "Material Request", source_name,