Merge pull request #55807 from aerele/fix/support-#70713

fix(stock): allow partial raw material pick/transfer from work order
This commit is contained in:
rohitwaghchaure
2026-06-17 14:10:29 +05:30
committed by GitHub
5 changed files with 168 additions and 13 deletions

View File

@@ -19,6 +19,7 @@ from erpnext.manufacturing.doctype.work_order.services.reservation import (
get_consumed_qty,
get_row_wise_serial_batch,
)
from erpnext.manufacturing.doctype.work_order.services.status import StatusService
from erpnext.stock.utils import get_bin, get_latest_stock_qty
@@ -146,6 +147,38 @@ class RequiredItemsService:
row, transferred_qty, row_wise_serial_batch
)
self.recompute_material_transferred_for_manufacturing(transferred_items)
def recompute_material_transferred_for_manufacturing(self, transferred_items):
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
# SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
sum_fg_completed_qty = StatusService(self.doc).get_transferred_or_manufactured_qty(
"Material Transfer for Manufacture", "material_transferred_for_manufacturing"
)
if sum_fg_completed_qty:
self.doc.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty)
return
# Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers
# so partial availability does not prematurely mark the work order as fully transferred.
required_by_item = {}
for row in self.doc.required_items:
if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
continue
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
if not required_by_item:
return
min_fraction = min(
flt(transferred_items.get(item_code) or 0) / required_qty
for item_code, required_qty in required_by_item.items()
)
min_fraction = min(min_fraction, 1.0)
material_transferred = min_fraction * flt(self.doc.qty)
self.doc.db_set("material_transferred_for_manufacturing", material_transferred)
def update_returned_qty(self):
returned_dict = self._material_transfer_qty_by_item(is_return=1)
for row in self.doc.required_items:

View File

@@ -1466,6 +1466,68 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(work_order.required_items[0].transferred_qty, 1)
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
def test_material_transferred_min_fraction_on_partial_pick_list(self):
"""Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing'
must reflect the least-transferred required item (the bottleneck), instead of being
marked fully transferred prematurely when only some materials are transferred.
"""
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0
)
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
# pick-list transfer: For Quantity = 0
transfer_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
)
self.assertEqual(transfer_entry.fg_completed_qty, 0.0)
for item in transfer_entry.items:
full_qty = required_qty[item.item_code]
item.qty = full_qty if item.item_code == "_Test Item" else full_qty / 2
item.transfer_qty = item.qty
transfer_entry.submit()
work_order.reload()
transferred_qty = {row.item_code: flt(row.transferred_qty) for row in work_order.required_items}
self.assertEqual(transferred_qty["_Test Item"], required_qty["_Test Item"])
self.assertEqual(
transferred_qty["_Test Item Home Desktop 100"],
required_qty["_Test Item Home Desktop 100"] / 2,
)
# bottleneck fraction = 0.5 -> 0.5 * qty(2) = 1.0
self.assertEqual(work_order.material_transferred_for_manufacturing, 1.0)
def test_material_transferred_full_via_pick_list_flow(self):
"""Pick-list flow with every required item fully transferred marks the work order
as fully transferred (min fraction = 1.0)."""
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0
)
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
transfer_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
)
self.assertEqual(transfer_entry.fg_completed_qty, 0.0)
for item in transfer_entry.items:
item.qty = required_qty[item.item_code]
item.transfer_qty = item.qty
transfer_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
def test_backflushed_batch_raw_materials_based_on_transferred(self):
frappe.db.set_single_value(
"Manufacturing Settings",

View File

@@ -1149,17 +1149,24 @@ erpnext.work_order = {
},
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
this.show_prompt_for_qty_input(frm, purpose)
.then((data) => {
return frappe.xcall("erpnext.manufacturing.doctype.work_order.mapper.create_pick_list", {
const max = this.get_max_transferable_qty(frm, purpose);
const get_pick_list = (for_qty) =>
frappe
.xcall("erpnext.manufacturing.doctype.work_order.mapper.create_pick_list", {
source_name: frm.doc.name,
for_qty: data.qty,
for_qty: for_qty,
})
.then((pick_list) => {
frappe.model.sync(pick_list);
frappe.set_route("Form", pick_list.doctype, pick_list.name);
});
})
.then((pick_list) => {
frappe.model.sync(pick_list);
frappe.set_route("Form", pick_list.doctype, pick_list.name);
});
if (max <= 0) {
get_pick_list(frm.doc.qty);
} else {
this.show_prompt_for_qty_input(frm, purpose).then((data) => get_pick_list(data.qty));
}
},
make_consumption_se: function (frm, backflush_raw_materials_based_on) {

View File

@@ -353,7 +353,7 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
stock_entry.from_bom = 1
stock_entry.bom_no = work_order.bom_no
stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
stock_entry.fg_completed_qty = pick_list.for_qty
stock_entry.fg_completed_qty = 0
if work_order.bom_no:
stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required")

View File

@@ -182,13 +182,66 @@ class MaterialTransferForManufactureStockEntry(BaseMaterialTransferStockEntry):
self.validate_same_source_target_warehouse()
def validate_component_and_quantities(self):
if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
if self.doc.fg_completed_qty:
if frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
_check_bom_component_qty(
self.doc, get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom)
)
elif self.doc.work_order:
self._validate_no_excess_transfer()
def _validate_no_excess_transfer(self):
if self.doc.is_return:
return
if not self.doc.fg_completed_qty:
if (
frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on")
== "Material Transferred for Manufacture"
):
return
_check_bom_component_qty(self.doc, get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom))
wo = self.wo_doc
if not wo:
return
pending_by_item = {}
for r in wo.required_items:
pending_by_item[r.item_code] = (
pending_by_item.get(r.item_code, 0.0) + flt(r.required_qty) - flt(r.transferred_qty)
)
transfer_by_item = {}
first_row_by_item = {}
for item in self.doc.items:
if not item.s_warehouse:
continue
key = (
item.item_code if item.item_code in pending_by_item else getattr(item, "original_item", None)
)
if key not in pending_by_item:
continue
transfer_by_item[key] = transfer_by_item.get(key, 0.0) + flt(item.qty)
first_row_by_item.setdefault(key, item)
for key, transfer_qty in transfer_by_item.items():
pending_qty = pending_by_item[key]
if transfer_qty > pending_qty:
item = first_row_by_item[key]
frappe.throw(
_(
"Row #{0}: Cannot transfer {1} {2} of Item {3}. "
"Maximum transferable quantity is {4} {2}."
).format(
item.idx,
transfer_qty,
item.uom,
frappe.bold(item.item_code),
pending_qty,
),
title=_("Excess Material Transfer"),
)
def add_items(self):
item_dict = self.get_pending_raw_materials()