From a858d7746141cd511de8519d3c324d9f3f111817 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sun, 21 Jun 2026 00:52:21 +0530 Subject: [PATCH 1/2] fix(stock): allow partial raw material picking/transfer from work order (cherry picked from commit 8e3fbab94ae6fbc19b378777a149b49e4f40175b) # Conflicts: # erpnext/manufacturing/doctype/work_order/work_order.py --- .../doctype/work_order/work_order.js | 25 +++++--- .../doctype/work_order/work_order.py | 30 ++++++++++ erpnext/stock/doctype/pick_list/pick_list.py | 2 +- .../stock/doctype/stock_entry/stock_entry.py | 59 ++++++++++++++++++- 4 files changed, 104 insertions(+), 12 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 3b3448333d9..5905d40b2f3 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -979,17 +979,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.work_order.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.work_order.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) { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 9d3100302e7..d6764005a80 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1241,6 +1241,36 @@ class WorkOrder(Document): "transferred_qty", (transferred_items.get(row.item_code) or 0.0), update_modified=False ) + 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 = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture") + if sum_fg_completed_qty: + self.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.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.qty) + self.db_set("material_transferred_for_manufacturing", material_transferred) + def update_returned_qty(self): ste = frappe.qb.DocType("Stock Entry") ste_child = frappe.qb.DocType("Stock Entry Detail") diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 41624477882..6d42f51a8d6 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1577,7 +1577,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") diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 69d8e96f6f2..3aea5271d3d 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1146,10 +1146,12 @@ class StockEntry(StockController): if self.purpose not in ["Manufacture", "Material Transfer for Manufacture"]: return - if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"): + if not self.fg_completed_qty: + if self.work_order and self.purpose == "Material Transfer for Manufacture": + self._validate_no_excess_transfer() return - if not self.fg_completed_qty: + if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"): return raw_materials = self.get_bom_raw_materials(self.fg_completed_qty) @@ -1174,6 +1176,59 @@ class StockEntry(StockController): title=_("Missing Item"), ) + def _validate_no_excess_transfer(self): + if self.is_return: + return + + if ( + frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on") + == "Material Transferred for Manufacture" + ): + return + + wo = self.pro_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.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 = max(0.0, 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 validate_same_source_target_warehouse_during_material_transfer(self): """ Validate Material Transfer entries where source and target warehouses are identical. From e6d45e35c752592223f3c64cec4d4ed7d327aa3d Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sun, 21 Jun 2026 00:53:53 +0530 Subject: [PATCH 2/2] test(stock): add test to validate the partial transfer of raw material (cherry picked from commit 4d055d374a5ca82b2dacc99b51cfe0c383401d5e) --- .../doctype/work_order/test_work_order.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index bf56182ee41..2679d6e29fe 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1461,6 +1461,68 @@ class TestWorkOrder(FrappeTestCase): 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",