Compare commits

...

10 Commits

Author SHA1 Message Date
Mihir Kandoi
ce0cdfb492 fix(manufacturing): bound transfer guard by the claim sum
The per-submit allowance guard projected stored effective transferred
qty plus the entry claim, so claim-less coverage (pick list, material
request) and corrective job card transfers wrongly consumed the claim
budget (test_corrective_job_card_transfer_excluded_from_transferred_qty).
Bound the projection by the claim sum, which already excludes corrective
and additional entries.
2026-08-13 12:20:13 +05:30
Mihir Kandoi
99e2538b25 test(manufacturing): cover rounding-loss snapping 2026-08-13 12:05:04 +05:30
Mihir Kandoi
6f301b1545 fix(manufacturing): snap near-full transfer coverage to landmarks
UOM conversion-factor truncation makes fully transferred rows sum
marginally below the requirement, storing values like 172.4789 for a
172.5 work order. Snap coverage fractions within 0.1% of a landmark
(full transfer, transfer allowance) to that landmark.
2026-08-13 12:04:52 +05:30
Mihir Kandoi
e5e2176da8 fix(manufacturing): restore transfer allowance guard per submit
The redesign dropped the claim-sum StockOverProductionError entirely,
letting a single entry claim more than planned qty plus allowance
(test_allow_overproduction). Validate on stock entry submit instead:
already-recorded effective transferred qty plus the submitting entry's
For Quantity must stay within the allowance. Cross-entry claim sums no
longer block the honest remainder after an under-covered entry, because
the recorded base is row-derived.
2026-08-13 12:04:51 +05:30
Mihir Kandoi
4e7d5aaced test(manufacturing): cover alternative-item return coverage 2026-08-13 11:46:25 +05:30
Mihir Kandoi
e4393ff16b fix(manufacturing): keep original_item on work order material returns
Return rows built from transferred materials dropped original_item, so a
returned alternative was keyed under the alternative item code and never
reduced the substituted required item's net coverage in
material_transferred_for_manufacturing.
2026-08-13 11:46:25 +05:30
Mihir Kandoi
9068fe93cf test(manufacturing): cover remainder, mixed-flow, and return coverage 2026-08-13 11:43:51 +05:30
Mihir Kandoi
754462801e fix(manufacturing): derive transferred qty from net item coverage
Follow-up to 329126a8d2; review findings: the claim capped away
legitimate coverage and the claim-sum validation still blocked the
honest remainder.

- material_transferred_for_manufacturing is now owned solely by the
  recomputation: finished-good qty covered by net item transfers
  (non-additional transfers minus returns, alternatives mapped to the
  original required item), capped at planned qty plus the transfer
  allowance instead of a hard 1.0 fraction.
- update_work_order_qty delegates the field to that recomputation and no
  longer validates SUM(fg_completed_qty) for transfers, so the
  advertised remainder submits after an under-covered entry; inflated
  claims cannot reach the stored value anyway.
- Coverage from For Quantity = 0 entries (pick list / material request)
  adds to claimed entries instead of being capped away, and returns
  reduce both the stored value and the In Process predicate.
2026-08-13 11:43:51 +05:30
Mihir Kandoi
4577930312 test(manufacturing): cover transferred qty capping 2026-08-13 11:42:55 +05:30
Mihir Kandoi
f084d72d84 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.
2026-08-13 11:42:55 +05:30
5 changed files with 269 additions and 48 deletions

View File

@@ -23,6 +23,8 @@ from erpnext.manufacturing.doctype.work_order.services.reservation import (
from erpnext.manufacturing.doctype.work_order.services.status import StatusService
from erpnext.stock.utils import get_bin, get_latest_stock_qty
_FULL_TRANSFER_TOLERANCE = 0.001
class RequiredItemsService:
def __init__(self, doc):
@@ -148,7 +150,7 @@ class RequiredItemsService:
row, transferred_qty, row_wise_serial_batch
)
self.recompute_material_transferred_for_manufacturing(transferred_items)
self.recompute_material_transferred_for_manufacturing()
def refresh_material_transferred_for_manufacturing(self):
"""Recompute material_transferred_for_manufacturing only, without touching per-row
@@ -157,26 +159,29 @@ class RequiredItemsService:
"""
if self.doc.skip_transfer:
return
transferred_items = self._material_transfer_qty_by_item(is_return=0)
self.recompute_material_transferred_for_manufacturing(transferred_items)
self.recompute_material_transferred_for_manufacturing()
def recompute_material_transferred_for_manufacturing(self, transferred_items):
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
def recompute_material_transferred_for_manufacturing(self):
"""Set material_transferred_for_manufacturing to the finished-good qty covered by net
item-level transfers (transfers minus returns), capped at the transfer allowance.
Falls back to the claimed SUM(fg_completed_qty) when coverage is unmeasurable.
"""
# 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(
"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
covered_qty = self._transfer_covered_qty()
if covered_qty is None:
covered_qty = StatusService(self.doc).get_transferred_or_manufactured_qty(
"Material Transfer for Manufacture", "material_transferred_for_manufacturing"
)
self.doc.db_set("material_transferred_for_manufacturing", covered_qty)
# 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.
def _transfer_covered_qty(self):
"""Finished-good qty covered by net transferred raw materials, None when unmeasurable.
Fractions marginally below a landmark (full transfer, transfer allowance) snap to it
so UOM-conversion rounding losses do not leave a full transfer marginally short.
"""
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 +189,32 @@ 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
net_transferred = self._net_transferred_qty_by_item()
min_fraction = min(
flt(transferred_items.get(item_code) or 0) / required_qty
flt(net_transferred.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)
allowance = StatusService(self.doc).get_qty_allowance("Material Transfer for Manufacture")
allowance_fraction = 1.0 + allowance / 100.0
min_fraction = self._snap_fraction(min_fraction, (1.0, allowance_fraction))
covered_qty = min(min_fraction, allowance_fraction) * flt(self.doc.qty)
return flt(covered_qty, self.doc.precision("material_transferred_for_manufacturing"))
def _snap_fraction(self, fraction, landmarks):
for landmark in landmarks:
if fraction < landmark and landmark - fraction <= _FULL_TRANSFER_TOLERANCE:
return landmark
return fraction
def _net_transferred_qty_by_item(self):
transferred = self._material_transfer_qty_by_item(is_return=0, exclude_additional=True)
returned = self._material_transfer_qty_by_item(is_return=1, exclude_additional=True)
net = frappe._dict()
for item_code, qty in transferred.items():
net[item_code] = max(0.0, flt(qty) - flt(returned.get(item_code) or 0.0))
return net
def update_returned_qty(self):
returned_dict = self._material_transfer_qty_by_item(is_return=1)
@@ -290,7 +312,7 @@ class RequiredItemsService:
)
return frappe._dict({d.item_code: flt(d.qty) for d in query.run(as_dict=1)})
def _material_transfer_qty_by_item(self, is_return):
def _material_transfer_qty_by_item(self, is_return, exclude_additional=False):
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
job_card = frappe.qb.DocType("Job Card")
@@ -311,7 +333,7 @@ class RequiredItemsService:
ste_child.original_item,
fn.Sum(ste_child.transfer_qty).as_("qty"),
)
.where(self._material_transfer_filter(ste, is_return))
.where(self._material_transfer_filter(ste, is_return, exclude_additional))
.where(fn.Coalesce(job_card.is_corrective_job_card, 0) == 0)
.groupby(ste_child.item_code, ste_child.original_item)
)
@@ -321,14 +343,16 @@ class RequiredItemsService:
qty_by_item[key] = (qty_by_item.get(key) or 0.0) + flt(d.qty)
if is_return:
return self._cap_returned_qty_to_transferred(qty_by_item)
return self._cap_returned_qty_to_transferred(qty_by_item, exclude_additional)
return qty_by_item
def _cap_returned_qty_to_transferred(self, returned_qty_by_item):
def _cap_returned_qty_to_transferred(self, returned_qty_by_item, exclude_additional=False):
# Work Order returns combine regular and corrective stock without a Job Card link.
# Cap each return at the regular transfer total so corrective quantities stay neutral.
transferred_qty_by_item = self._material_transfer_qty_by_item(is_return=0)
transferred_qty_by_item = self._material_transfer_qty_by_item(
is_return=0, exclude_additional=exclude_additional
)
return frappe._dict(
{
item_code: min(flt(returned_qty), flt(transferred_qty_by_item.get(item_code)))
@@ -336,13 +360,16 @@ class RequiredItemsService:
}
)
def _material_transfer_filter(self, ste, is_return):
return (
def _material_transfer_filter(self, ste, is_return, exclude_additional=False):
condition = (
(ste.docstatus == 1)
& (ste.work_order == self.doc.name)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == is_return)
)
if exclude_additional:
condition &= ste.is_additional_transfer_entry == 0
return condition
def update_consumed_qty_for_required_items(self):
"""

View File

@@ -10,6 +10,7 @@ callers (job cards, sales orders, production plans, patches) keep working.
import frappe
from frappe import _
from frappe.query_builder import Case
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import cint, flt, get_link_to_form
@@ -88,9 +89,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,30 +145,20 @@ 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 raw material net of returns remains 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())
)
signed_qty = Case().when(ste.is_return == 1, -ste_child.transfer_qty).else_(ste_child.transfer_qty)
qty = (
frappe.qb.from_(ste)
.inner_join(ste_child)
.on(ste_child.parent == ste.name)
.select(Sum(ste_child.transfer_qty))
.select(Sum(signed_qty))
.where(
(ste.work_order == self.doc.name)
& (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
@@ -211,8 +202,16 @@ class StatusService:
if self._skip_transfer_purpose(purpose):
return
if fieldname == "material_transferred_for_manufacturing":
# Owned by the net-coverage recomputation; the per-entry allowance guard runs on
# stock entry submit, where the submitting entry's claim is known.
self.doc.refresh_material_transferred_for_manufacturing()
self.set_process_loss_qty()
self._update_produced_qty_in_so()
return
qty = self.get_transferred_or_manufactured_qty(purpose, fieldname)
completed_qty = self.doc.qty + (self._qty_allowance(purpose) / 100 * self.doc.qty)
completed_qty = self.doc.qty + (self.get_qty_allowance(purpose) / 100 * self.doc.qty)
if qty > completed_qty:
frappe.throw(
_("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format(
@@ -232,7 +231,7 @@ class StatusService:
and self.doc.transfer_material_against == "Job Card"
)
def _qty_allowance(self, purpose):
def get_qty_allowance(self, purpose):
allowance = flt(
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
)

View File

@@ -1480,9 +1480,10 @@ class TestWorkOrder(ERPNextTestSuite):
del transfer_entry.get("items")[0] # transfer only one RM
transfer_entry.submit()
# WO's "Material Transferred for Mfg" shows all is transferred, one RM is pending
# For Quantity claimed 1, but the untouched RM caps the covered qty at 0
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
self.assertEqual(work_order.material_transferred_for_manufacturing, 0)
self.assertEqual(work_order.status, "In Process")
self.assertEqual(work_order.required_items[0].transferred_qty, 0)
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
@@ -1564,6 +1565,163 @@ class TestWorkOrder(ERPNextTestSuite):
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
def test_material_transferred_capped_by_actual_item_transfers(self):
"""A transfer entry claiming For Quantity for the whole work order while its rows
carry less must only count the covered qty; the remainder stays transferable."""
work_order = make_wo_order_test_record(planned_start_date=now(), qty=4)
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=20, basic_rate=1000.0
)
transfer_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 4)
)
for item in transfer_entry.items:
if item.item_code == "_Test Item":
item.qty = 1
item.transfer_qty = 1
transfer_entry.submit()
work_order.reload()
self.assertEqual(transfer_entry.fg_completed_qty, 4.0)
self.assertEqual(work_order.material_transferred_for_manufacturing, 1.0)
self.assertEqual(work_order.status, "In Process")
remainder_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 3)
)
remainder_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 4.0)
self.assertEqual(work_order.required_items[0].transferred_qty, 4.0)
def test_material_transferred_counts_mixed_direct_and_pick_list_transfers(self):
"""Coverage from For Quantity = 0 entries (pick list / material request flow) must add
to coverage from claimed entries instead of being capped away by the claim."""
work_order = make_wo_order_test_record(planned_start_date=now(), qty=4)
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
)
direct_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 1)
)
direct_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 1.0)
pick_list_style_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
)
pick_list_style_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 4.0)
self.assertEqual(work_order.status, "In Process")
def test_material_transferred_reduced_by_returns(self):
"""Returning raw material from WIP must reduce material_transferred_for_manufacturing."""
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
)
transfer_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 2)
)
transfer_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
return_entry = make_stock_return_entry(work_order.name)
return_entry.company = work_order.company
for row in list(return_entry.items):
if row.item_code != "_Test Item":
return_entry.remove(row)
return_entry.items[0].qty = 1
return_entry.save()
return_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 1.0)
self.assertEqual(work_order.status, "In Process")
def test_material_transferred_reduced_by_alternative_item_returns(self):
"""Returned alternative items must reduce coverage of the required item they substituted."""
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
alternative_item = make_item(
"Alternative RM For WO Coverage", {"is_stock_item": 1, "stock_uom": "_Test UOM"}
)
test_stock_entry.make_stock_entry(
item_code=alternative_item.name, target="_Test Warehouse - _TC", qty=10, basic_rate=100.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0
)
transfer_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 2)
)
for item in transfer_entry.items:
if item.item_code == "_Test Item":
item.item_code = alternative_item.name
item.original_item = "_Test Item"
transfer_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
return_entry = make_stock_return_entry(work_order.name)
return_entry.company = work_order.company
for row in list(return_entry.items):
if row.item_code != alternative_item.name:
return_entry.remove(row)
self.assertEqual(return_entry.items[0].original_item, "_Test Item")
return_entry.items[0].qty = 1
return_entry.save()
return_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 1.0)
def test_material_transferred_snaps_conversion_rounding_losses(self):
"""A full transfer whose rows come out marginally short from UOM-conversion rounding
must count as fully transferred instead of storing values like 172.4789 for 172.5."""
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2000)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="_Test Warehouse - _TC", qty=5000, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100",
target="_Test Warehouse - _TC",
qty=5000,
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)
)
for item in transfer_entry.items:
item.qty = required_qty[item.item_code] * 0.9995
item.transfer_qty = item.qty
transfer_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 2000.0)
def test_status_in_process_when_only_one_required_item_transferred(self):
"""Stock Entry created from a Pick List that picked only one of the required items:
min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must

View File

@@ -669,6 +669,8 @@ class ManufactureStockEntry(BaseManufactureStockEntry):
item_args["qty"] = ceil_qty_if_uom_has_whole_number(qty, row.uom)
item_args["transfer_qty"] = item_args["qty"]
if is_return:
if row.get("original_item"):
item_args["original_item"] = row.original_item
item_args["s_warehouse"], item_args["t_warehouse"] = row.s_warehouse, row.t_warehouse
else:
item_args["t_warehouse"], item_args["s_warehouse"] = None, row.warehouse

View File

@@ -382,6 +382,7 @@ class MaterialTransferForManufactureStockEntry(BaseMaterialTransferStockEntry):
if self.doc.fg_completed_qty:
if self.doc.docstatus == 1:
self.wo_doc.add_additional_items(self.doc)
self._validate_transfer_within_allowance()
else:
self.wo_doc.remove_additional_items(self.doc)
@@ -391,6 +392,40 @@ class MaterialTransferForManufactureStockEntry(BaseMaterialTransferStockEntry):
if not self.wo_doc.operations:
self.wo_doc.set_actual_dates()
def _validate_transfer_within_allowance(self):
"""Reject a transfer whose For Quantity, on top of the effective qty already
transferred, exceeds the planned qty plus the transfer allowance. The projection
is bounded by the claim sum, which already excludes corrective job card and
additional transfer entries, so claim-less coverage never consumes the budget."""
from erpnext.manufacturing.doctype.work_order.services.status import StatusService
from erpnext.manufacturing.doctype.work_order.work_order import StockOverProductionError
if self.doc.is_return or self.doc.is_additional_transfer_entry:
return
if self.wo_doc.track_semi_finished_goods:
return
if self.wo_doc.operations and self.wo_doc.transfer_material_against == "Job Card":
return
status_service = StatusService(self.wo_doc)
allowance = status_service.get_qty_allowance("Material Transfer for Manufacture")
allowed_qty = flt(self.wo_doc.qty) * (1.0 + allowance / 100.0)
transferred_qty = flt(self.wo_doc.material_transferred_for_manufacturing)
claimed_qty = status_service.get_transferred_or_manufactured_qty(
"Material Transfer for Manufacture", "material_transferred_for_manufacturing"
)
projected_qty = min(transferred_qty + flt(self.doc.fg_completed_qty), claimed_qty)
precision = self.wo_doc.precision("material_transferred_for_manufacturing")
if flt(projected_qty, precision) <= flt(allowed_qty, precision):
return
frappe.throw(
_(
"For Quantity ({0}) with the already transferred quantity ({1}) cannot be greater than allowed quantity ({2}) in Work Order {3}"
).format(flt(self.doc.fg_completed_qty), transferred_qty, allowed_qty, self.wo_doc.name),
StockOverProductionError,
)
class MaterialRequestStockEntry(BaseMaterialTransferStockEntry):
def before_validate(self):