From 213adc9ebe75cdf28a445f1bf11cb7c52d604ee1 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Thu, 11 Jun 2026 15:36:48 +0530 Subject: [PATCH] fix(stock): allow partial raw material picking/transfer from work order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When creating a pick list for a work order with partially available stock, the resulting Material Transfer for Manufacture stock entry was setting fg_completed_qty = for_qty (= wo.qty), causing material_transferred_for_manufacturing to reach wo.qty after just one partial transfer and blocking further pick lists. Fix: - Set fg_completed_qty = 0 on stock entries created from pick lists so the old SUM(fg_completed_qty) path never fires prematurely - Recompute material_transferred_for_manufacturing after each transfer: use SUM(fg_completed_qty) when > 0 (direct entries / excess transfer), otherwise use min(transferred/required) × wo.qty (pick list flow) - Add _validate_no_excess_transfer for pick list entries (fg_completed_qty=0) to prevent transferring more than pending qty; skip for return entries and when backflush is based on Material Transferred for Manufacture - Remove the zero-qty prompt in pick list work_order trigger; skip the qty dialog in work_order.js when max transferable qty is already 0 - Hide fg_completed_qty field in Stock Entry for Material Transfer for Manufacture purpose since it is unused in that flow Fixes: #70713, #63846 --- .../work_order/services/required_items.py | 33 +++++++++++ .../doctype/work_order/work_order.js | 25 +++++--- erpnext/stock/doctype/pick_list/mapper.py | 2 +- .../stock_entry/services/material_transfer.py | 59 ++++++++++++++++++- 4 files changed, 106 insertions(+), 13 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/services/required_items.py b/erpnext/manufacturing/doctype/work_order/services/required_items.py index 26c9cf155b1..235477f106b 100644 --- a/erpnext/manufacturing/doctype/work_order/services/required_items.py +++ b/erpnext/manufacturing/doctype/work_order/services/required_items.py @@ -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: diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 7280e9bf044..28dbf5060bf 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -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) { diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py index 0ee754ce2ed..2d7f0e91ab8 100644 --- a/erpnext/stock/doctype/pick_list/mapper.py +++ b/erpnext/stock/doctype/pick_list/mapper.py @@ -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") diff --git a/erpnext/stock/doctype/stock_entry/services/material_transfer.py b/erpnext/stock/doctype/stock_entry/services/material_transfer.py index c7d6ee05495..4e3b2364b75 100644 --- a/erpnext/stock/doctype/stock_entry/services/material_transfer.py +++ b/erpnext/stock/doctype/stock_entry/services/material_transfer.py @@ -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()