feat(manufacturing): create material request for raw materials from work order (#56961)

* feat(manufacturing): create material request for raw materials from work order

allow raising a material transfer request directly from a work order,
mirroring the existing job card flow, so stores can fulfil it into wip
before the actual stock entry happens

* test(manufacturing): cover work order material request flow

verify the material request created from a work order carries the
right bom/purpose onto the resulting stock entry, and that the work
order status still moves to in process on a partial material-request
transfer
This commit is contained in:
Pandiyan P
2026-07-09 17:27:21 +05:30
committed by GitHub
parent d418dd9e70
commit e2178a5f19
6 changed files with 129 additions and 2 deletions

View File

@@ -515,6 +515,41 @@ def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished
target.conversion_factor = 1
@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": [
("required_qty", "qty"),
("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: str):
from erpnext.stock.doctype.stock_entry.services.manufacturing import (

View File

@@ -145,9 +145,18 @@ class StatusService:
def _has_transferred_material(self):
"""True if any raw material was transferred against this work order via a pick list
(these leave material_transferred_for_manufacturing at 0 via the min-fraction rule)."""
or a material request (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_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; once
# that's established, sum every row's transfer_qty, not just the linked ones (a
# manually appended extra row on the same entry has no material_request of its own).
mr_sourced_stock_entries = (
frappe.qb.from_(mr_child).select(mr_child.parent).where(mr_child.material_request.isnotnull())
)
qty = (
frappe.qb.from_(ste)
.inner_join(ste_child)
@@ -158,7 +167,7 @@ class StatusService:
& (ste.docstatus == 1)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == 0)
& (ste.pick_list.isnotnull())
& (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries))
)
).run()[0][0]
return flt(qty) > 0

View File

@@ -12,6 +12,7 @@ from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError
from erpnext.manufacturing.doctype.job_card.mapper import make_stock_entry as make_stock_entry_from_jc
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.mapper import (
make_material_request,
make_stock_entry,
make_stock_return_entry,
)
@@ -1582,6 +1583,61 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
self.assertEqual(work_order.status, "In Process")
def test_work_order_material_request_and_bom_details(self):
from erpnext.stock.doctype.material_request.mapper 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.mapper 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",

View File

@@ -822,6 +822,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");
});
@@ -1157,6 +1161,13 @@ erpnext.work_order = {
}
},
make_material_request: function (frm) {
frappe.model.open_mapped_doc({
method: "erpnext.manufacturing.doctype.work_order.mapper.make_material_request",
frm,
});
},
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
const max = this.get_max_transferable_qty(frm, purpose);

View File

@@ -34,6 +34,7 @@ from erpnext.manufacturing.doctype.work_order.mapper import (
get_template_rm_item,
get_work_order_operation_data,
make_job_card,
make_material_request,
make_stock_entry,
make_stock_return_entry,
make_work_order,

View File

@@ -264,6 +264,9 @@ def make_stock_entry(source_name: str, target_doc: str | Document | None = 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"
@@ -282,6 +285,18 @@ def make_stock_entry(source_name: str, target_doc: str | Document | None = 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,