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.
This commit is contained in:
Mihir Kandoi
2026-08-13 10:02:29 +05:30
parent 1fd653b1d3
commit f084d72d84
2 changed files with 22 additions and 27 deletions

View File

@@ -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)

View File

@@ -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