From f084d72d849c2b6d7824cacfc184909321112849 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 13 Aug 2026 10:02:29 +0530 Subject: [PATCH] fix(manufacturing): cap transferred qty by actual material coverage A Material Transfer for Manufacture entry could claim any For Quantity regardless of what its rows carry; the work order copied that claim into material_transferred_for_manufacturing on submit. Editing rows down after generating the entry marked the work order fully transferred, blocking further transfers and allowing manufacture entries without material. Cap SUM(fg_completed_qty) by the finished-good qty the transferred item quantities actually cover (the pick-list min-fraction rule). Status now treats any raw-material transfer as material movement, not only pick list or material request sourced entries, so a zero-coverage partial transfer still moves the work order to In Process. --- .../work_order/services/required_items.py | 29 +++++++++++-------- .../doctype/work_order/services/status.py | 20 ++++--------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/services/required_items.py b/erpnext/manufacturing/doctype/work_order/services/required_items.py index 4fb6bf2b05a..2d4a7436f24 100644 --- a/erpnext/manufacturing/doctype/work_order/services/required_items.py +++ b/erpnext/manufacturing/doctype/work_order/services/required_items.py @@ -161,22 +161,28 @@ class RequiredItemsService: 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.""" + """Set material_transferred_for_manufacturing to the claimed SUM(fg_completed_qty), + capped by the finished-good qty the transferred item quantities actually cover. + """ # Job Card transfers use the minimum completed quantity across operations. if self.doc.operations and self.doc.transfer_material_against == "Job Card": return - # 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( + claimed_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) + covered_qty = self._transfer_covered_qty(transferred_items) + + if covered_qty is None: + if claimed_qty: + self.doc.db_set("material_transferred_for_manufacturing", claimed_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. + material_transferred = min(claimed_qty, covered_qty) if claimed_qty else covered_qty + self.doc.db_set("material_transferred_for_manufacturing", material_transferred) + + def _transfer_covered_qty(self, transferred_items): + """Finished-good qty covered by the transferred raw materials, None when unmeasurable.""" required_by_item = {} for row in self.doc.required_items: if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0: @@ -184,15 +190,14 @@ class RequiredItemsService: 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 + return None 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) + covered_qty = min(min_fraction, 1.0) * flt(self.doc.qty) + return flt(covered_qty, self.doc.precision("material_transferred_for_manufacturing")) def update_returned_qty(self): returned_dict = self._material_transfer_qty_by_item(is_return=1) diff --git a/erpnext/manufacturing/doctype/work_order/services/status.py b/erpnext/manufacturing/doctype/work_order/services/status.py index 899f11a939d..7b3fb42cf2b 100644 --- a/erpnext/manufacturing/doctype/work_order/services/status.py +++ b/erpnext/manufacturing/doctype/work_order/services/status.py @@ -88,9 +88,9 @@ class StatusService: def update_status(self, status=None): """Update status of work order if unknown""" if self.doc.docstatus == 1: - # Refresh material_transferred_for_manufacturing before deciding status so pick-list- - # driven transfers (where this qty is derived from item transfers, not fg_completed_qty) - # are reflected immediately, instead of only after the next status update call. + # Refresh material_transferred_for_manufacturing before deciding status so the + # item-level transfer coverage is reflected immediately, instead of only after + # the next status update call. self.doc.refresh_material_transferred_for_manufacturing() if self.doc.status != "Closed": @@ -144,19 +144,10 @@ class StatusService: return status def _has_transferred_material(self): - """True if any raw material was transferred against this work order via a pick list - or a material request (these leave material_transferred_for_manufacturing at 0 via - the min-fraction rule).""" + """True if any raw material was transferred against this work order, even when the + covered qty leaves material_transferred_for_manufacturing at 0.""" 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) @@ -167,7 +158,6 @@ class StatusService: & (ste.docstatus == 1) & (ste.purpose == "Material Transfer for Manufacture") & (ste.is_return == 0) - & (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries)) ) ).run()[0][0] return flt(qty) > 0