mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-14 23:18:40 +00:00
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
This commit is contained in:
@@ -18,6 +18,7 @@ from erpnext.manufacturing.doctype.work_order.work_order import (
|
|||||||
StockOverProductionError,
|
StockOverProductionError,
|
||||||
close_work_order,
|
close_work_order,
|
||||||
make_job_card,
|
make_job_card,
|
||||||
|
make_material_request,
|
||||||
make_stock_entry,
|
make_stock_entry,
|
||||||
make_stock_return_entry,
|
make_stock_return_entry,
|
||||||
stop_unstop,
|
stop_unstop,
|
||||||
@@ -1547,6 +1548,65 @@ class TestWorkOrder(ERPNextTestSuite):
|
|||||||
work_order.reload()
|
work_order.reload()
|
||||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
|
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):
|
def test_backflushed_batch_raw_materials_based_on_transferred(self):
|
||||||
frappe.db.set_single_value(
|
frappe.db.set_single_value(
|
||||||
"Manufacturing Settings",
|
"Manufacturing Settings",
|
||||||
|
|||||||
@@ -813,6 +813,10 @@ erpnext.work_order = {
|
|||||||
erpnext.work_order.create_pick_list(frm);
|
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 () {
|
var start_btn = frm.add_custom_button(__("Start"), function () {
|
||||||
erpnext.work_order.make_se(frm, "Material Transfer for Manufacture");
|
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") {
|
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
|
||||||
const max = this.get_max_transferable_qty(frm, purpose);
|
const max = this.get_max_transferable_qty(frm, purpose);
|
||||||
|
|
||||||
|
|||||||
@@ -677,7 +677,11 @@ class WorkOrder(Document):
|
|||||||
elif self.docstatus == 1:
|
elif self.docstatus == 1:
|
||||||
if status not in ["Closed", "Stopped"]:
|
if status not in ["Closed", "Stopped"]:
|
||||||
status = "Not Started"
|
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"
|
status = "In Process"
|
||||||
|
|
||||||
precision = frappe.get_precision("Work Order", "produced_qty")
|
precision = frappe.get_precision("Work Order", "produced_qty")
|
||||||
@@ -711,6 +715,57 @@ class WorkOrder(Document):
|
|||||||
|
|
||||||
return status
|
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):
|
def update_work_order_qty(self):
|
||||||
"""Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order
|
"""Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order
|
||||||
based on Stock Entry"""
|
based on Stock Entry"""
|
||||||
@@ -3014,6 +3069,40 @@ def get_reserved_qty_for_production(
|
|||||||
return query.run()[0][0] or 0.0
|
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()
|
@frappe.whitelist()
|
||||||
def make_stock_return_entry(work_order):
|
def make_stock_return_entry(work_order):
|
||||||
from erpnext.stock.doctype.stock_entry.stock_entry import get_available_materials
|
from erpnext.stock.doctype.stock_entry.stock_entry import get_available_materials
|
||||||
|
|||||||
@@ -717,7 +717,7 @@ def make_supplier_quotation(source_name, target_doc=None):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@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):
|
def update_item(obj, target, source_parent):
|
||||||
qty = (
|
qty = (
|
||||||
flt(flt(obj.stock_qty) - flt(obj.ordered_qty)) / target.conversion_factor
|
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:
|
if source.job_card:
|
||||||
target.purpose = "Material Transfer for Manufacture"
|
target.purpose = "Material Transfer for Manufacture"
|
||||||
|
|
||||||
|
if source.work_order:
|
||||||
|
target.purpose = "Material Transfer for Manufacture"
|
||||||
|
|
||||||
if source.material_request_type == "Customer Provided":
|
if source.material_request_type == "Customer Provided":
|
||||||
target.purpose = "Material Receipt"
|
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.fg_completed_qty = job_card_details[0].for_quantity
|
||||||
target.from_bom = 1
|
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(
|
doclist = get_mapped_doc(
|
||||||
"Material Request",
|
"Material Request",
|
||||||
source_name,
|
source_name,
|
||||||
|
|||||||
Reference in New Issue
Block a user