From 1534e2fad65545f8511fd2ad0e9c78fd668ea69c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 13 Aug 2026 15:06:03 +0530 Subject: [PATCH] fix(manufacturing): derive material transfers from actual coverage (#58110) --- .../work_order/services/material_coverage.py | 22 +++ .../work_order/services/required_items.py | 25 ++-- .../doctype/work_order/services/status.py | 13 +- .../doctype/work_order/test_work_order.py | 130 +++++++++++++++++- erpnext/patches.txt | 1 + .../repair_work_order_material_transfer.py | 66 +++++++++ .../stock_entry/services/material_transfer.py | 56 ++++++++ 7 files changed, 285 insertions(+), 28 deletions(-) create mode 100644 erpnext/manufacturing/doctype/work_order/services/material_coverage.py create mode 100644 erpnext/patches/v16_0/repair_work_order_material_transfer.py diff --git a/erpnext/manufacturing/doctype/work_order/services/material_coverage.py b/erpnext/manufacturing/doctype/work_order/services/material_coverage.py new file mode 100644 index 00000000000..8363e0c1284 --- /dev/null +++ b/erpnext/manufacturing/doctype/work_order/services/material_coverage.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from collections.abc import Mapping + +from frappe.utils import flt + + +def get_minimum_material_coverage_fraction( + required_qty: Mapping[str, float], transferred_qty: Mapping[str, float], precision: int +) -> float: + """Return the least-covered component ratio at the configured quantity precision.""" + coverage = [] + for item_code, required in required_qty.items(): + transferred = flt(transferred_qty.get(item_code)) + # Stored values can differ after the digits that the user can enter or see. + if flt(transferred, precision) == flt(required, precision): + coverage.append(1.0) + else: + coverage.append(transferred / required) + + return min(coverage, default=0.0) diff --git a/erpnext/manufacturing/doctype/work_order/services/required_items.py b/erpnext/manufacturing/doctype/work_order/services/required_items.py index 4fb6bf2b05a..9d1b02fd440 100644 --- a/erpnext/manufacturing/doctype/work_order/services/required_items.py +++ b/erpnext/manufacturing/doctype/work_order/services/required_items.py @@ -15,6 +15,9 @@ from pypika import functions as fn from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict from erpnext.manufacturing.doctype.work_order.mapper import check_if_scrap_warehouse_mandatory +from erpnext.manufacturing.doctype.work_order.services.material_coverage import ( + get_minimum_material_coverage_fraction, +) from erpnext.manufacturing.doctype.work_order.services.reservation import ( WorkOrderStockReservation, get_consumed_qty, @@ -161,22 +164,15 @@ 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 transferred quantity from the raw materials that have actually moved.""" # 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) - 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: @@ -186,12 +182,13 @@ class RequiredItemsService: 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 = get_minimum_material_coverage_fraction( + required_by_item, + transferred_items, + self.doc.precision("required_qty", "required_items"), ) - min_fraction = min(min_fraction, 1.0) - material_transferred = min_fraction * flt(self.doc.qty) + covered_qty = min_fraction * flt(self.doc.qty) + material_transferred = min(covered_qty, max(flt(self.doc.qty), claimed_qty)) self.doc.db_set("material_transferred_for_manufacturing", material_transferred) def update_returned_qty(self): diff --git a/erpnext/manufacturing/doctype/work_order/services/status.py b/erpnext/manufacturing/doctype/work_order/services/status.py index 899f11a939d..d8284bc3d8d 100644 --- a/erpnext/manufacturing/doctype/work_order/services/status.py +++ b/erpnext/manufacturing/doctype/work_order/services/status.py @@ -144,19 +144,9 @@ 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.""" 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 +157,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 diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 513974c0adb..bc2163acf06 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1480,9 +1480,11 @@ 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 + # One required item is still missing, so no finished-good quantity is covered yet. work_order.reload() - self.assertEqual(work_order.material_transferred_for_manufacturing, 1) + self.assertEqual(transfer_entry.fg_completed_qty, 0) + 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) @@ -1502,6 +1504,39 @@ class TestWorkOrder(ERPNextTestSuite): self.assertEqual(work_order.required_items[0].transferred_qty, 1) self.assertEqual(work_order.required_items[1].transferred_qty, 2) + def test_material_transfer_claim_follows_actual_coverage(self): + 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 row in transfer_entry.items: + if row.item_code == "_Test Item": + row.qty = 1 + transfer_entry.submit() + + work_order.reload() + self.assertEqual(transfer_entry.fg_completed_qty, 1) + self.assertEqual(work_order.material_transferred_for_manufacturing, 1) + + remainder_entry = frappe.get_doc( + make_stock_entry(work_order.name, "Material Transfer for Manufacture", 3) + ) + remainder_entry.submit() + + work_order.reload() + self.assertEqual(remainder_entry.fg_completed_qty, 3) + self.assertEqual(work_order.material_transferred_for_manufacturing, 4) + 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 @@ -1564,6 +1599,97 @@ class TestWorkOrder(ERPNextTestSuite): work_order.reload() self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0) + def test_material_transferred_ignores_hidden_precision_difference(self): + 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, + ) + + precision = work_order.precision("required_qty", "required_items") + hidden_difference = 4 / (10 ** (precision + 1)) + row = work_order.required_items[0] + row.db_set("required_qty", flt(row.required_qty) + hidden_difference, update_modified=False) + work_order.reload() + 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 = flt(required_qty[item.item_code], precision) + item.transfer_qty = item.qty + transfer_entry.submit() + + work_order.reload() + self.assertEqual( + flt(work_order.required_items[0].required_qty, precision), + flt(work_order.required_items[0].transferred_qty, precision), + ) + self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty) + + def test_repair_material_transfer_precision_patch(self): + from erpnext.patches.v16_0.repair_work_order_material_transfer import ( + execute, + get_precision_affected_work_orders, + ) + + precision = frappe.get_precision("Work Order Item", "required_qty") + hidden_difference = 4 / (10 ** (precision + 1)) + work_order = make_wo_order_test_record(planned_start_date=now(), qty=2) + for index, row in enumerate(work_order.required_items): + required_qty = flt(row.required_qty) + (hidden_difference if index == 0 else 0) + row.db_set( + { + "required_qty": required_qty, + "transferred_qty": flt(required_qty, precision), + }, + update_modified=False, + ) + work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False) + + partial_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2) + for row in partial_work_order.required_items: + row.db_set("transferred_qty", row.required_qty, update_modified=False) + partial_row = partial_work_order.required_items[0] + partial_row.db_set( + "transferred_qty", + flt(partial_row.required_qty, precision) - (1 / (10**precision)), + update_modified=False, + ) + partial_work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False) + + terminal_work_orders = [] + for status in ("Stopped", "Closed", "Completed"): + terminal_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2) + for row in terminal_work_order.required_items: + row.db_set("transferred_qty", row.required_qty, update_modified=False) + terminal_work_order.db_set( + {"material_transferred_for_manufacturing": 1.99, "status": status}, + update_modified=False, + ) + terminal_work_orders.append(terminal_work_order) + + updates = get_precision_affected_work_orders() + self.assertIn(work_order.name, updates) + self.assertNotIn(partial_work_order.name, updates) + for terminal_work_order in terminal_work_orders: + self.assertNotIn(terminal_work_order.name, updates) + + execute() + work_order.reload() + partial_work_order.reload() + self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty) + self.assertEqual(partial_work_order.material_transferred_for_manufacturing, 1.99) + for terminal_work_order in terminal_work_orders: + terminal_work_order.reload() + self.assertEqual(terminal_work_order.material_transferred_for_manufacturing, 1.99) + 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 diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 251e7eb83b7..e023cff4b3f 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -512,3 +512,4 @@ erpnext.patches.v16_0.set_work_order_requested_and_picked_qty erpnext.patches.v16_0.rename_italy_customer_name_fields erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status erpnext.patches.v16_0.recalculate_mixed_purchase_receipt_billing_status +erpnext.patches.v16_0.repair_work_order_material_transfer diff --git a/erpnext/patches/v16_0/repair_work_order_material_transfer.py b/erpnext/patches/v16_0/repair_work_order_material_transfer.py new file mode 100644 index 00000000000..94458b34466 --- /dev/null +++ b/erpnext/patches/v16_0/repair_work_order_material_transfer.py @@ -0,0 +1,66 @@ +import frappe +from frappe.utils import flt +from pypika import functions as fn + +from erpnext.manufacturing.doctype.work_order.services.material_coverage import ( + get_minimum_material_coverage_fraction, +) + + +def execute(): + updates = get_precision_affected_work_orders() + frappe.db.bulk_update("Work Order", updates, update_modified=False) + + +def get_precision_affected_work_orders(): + """Return Work Orders whose components cover the plan at quantity precision.""" + work_orders = {} + for row in _get_candidate_rows(): + work_order = work_orders.setdefault( + row.work_order, + {"qty": flt(row.qty), "required_qty": {}, "transferred_qty": {}}, + ) + item_code = row.item_code + work_order["required_qty"][item_code] = work_order["required_qty"].get(item_code, 0.0) + flt( + row.required_qty + ) + work_order["transferred_qty"][item_code] = max( + work_order["transferred_qty"].get(item_code, 0.0), flt(row.transferred_qty) + ) + + precision = frappe.get_precision("Work Order Item", "required_qty") + return { + name: {"material_transferred_for_manufacturing": values["qty"]} + for name, values in work_orders.items() + if get_minimum_material_coverage_fraction( + values["required_qty"], values["transferred_qty"], precision + ) + >= 1.0 + } + + +def _get_candidate_rows(): + work_order = frappe.qb.DocType("Work Order") + required_item = frappe.qb.DocType("Work Order Item") + return ( + frappe.qb.from_(work_order) + .inner_join(required_item) + .on(required_item.parent == work_order.name) + .select( + work_order.name.as_("work_order"), + work_order.qty, + required_item.item_code, + required_item.required_qty, + required_item.transferred_qty, + ) + .where( + (work_order.docstatus == 1) + & (work_order.status.notin(["Stopped", "Closed", "Completed"])) + & (fn.Coalesce(work_order.skip_transfer, 0) == 0) + & (fn.Coalesce(work_order.track_semi_finished_goods, 0) == 0) + & (fn.Coalesce(work_order.material_transferred_for_manufacturing, 0) < work_order.qty) + & (fn.Coalesce(work_order.transfer_material_against, "") != "Job Card") + & (required_item.include_item_in_manufacturing == 1) + & (required_item.required_qty > 0) + ) + ).run(as_dict=True) diff --git a/erpnext/stock/doctype/stock_entry/services/material_transfer.py b/erpnext/stock/doctype/stock_entry/services/material_transfer.py index 11933cf4c9d..03fe8cff75c 100644 --- a/erpnext/stock/doctype/stock_entry/services/material_transfer.py +++ b/erpnext/stock/doctype/stock_entry/services/material_transfer.py @@ -3,6 +3,10 @@ from frappe import _ from frappe.query_builder.functions import Sum from frappe.utils import cstr, flt +from erpnext.manufacturing.doctype.work_order.services.material_coverage import ( + get_minimum_material_coverage_fraction, +) + from .manufacturing import _check_bom_component_qty, get_bom_items from .stock_entry_base import BaseStockEntry @@ -179,8 +183,60 @@ class MaterialTransferForManufactureStockEntry(BaseMaterialTransferStockEntry): def validate(self): self.validate_warehouse() self.validate_component_and_quantities() + self._cap_completed_qty_to_material_coverage() self.validate_same_source_target_warehouse() + def _cap_completed_qty_to_material_coverage(self): + if not self._should_cap_completed_qty(): + return + # Keep an excessive claim intact so the Work Order allowance check can reject it. + if not self._is_overproduction_allowed(flt(self.wo_doc.qty)): + return + + required_qty, transferred_qty = self._get_work_order_material_qty() + if not required_qty: + return + + covered_before = self._get_covered_work_order_qty(required_qty, transferred_qty) + for row in self.doc.items: + item_code = row.original_item or row.item_code + if row.s_warehouse and item_code in required_qty: + transferred_qty[item_code] += flt(row.qty) * flt(row.conversion_factor or 1) + + covered_after = self._get_covered_work_order_qty(required_qty, transferred_qty) + covered_by_entry = flt(max(covered_after - covered_before, 0), self.doc.precision("fg_completed_qty")) + self.doc.fg_completed_qty = min(flt(self.doc.fg_completed_qty), covered_by_entry) + + def _should_cap_completed_qty(self): + if self.doc.get("_action") != "submit": + return False + if not self.wo_doc or not self.doc.fg_completed_qty: + return False + if self.doc.is_return or self.doc.is_additional_transfer_entry: + return False + return not (self.wo_doc.operations and self.wo_doc.transfer_material_against == "Job Card") + + def _get_work_order_material_qty(self): + required_qty = {} + transferred_qty = {} + for row in self.wo_doc.required_items: + if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0: + continue + required_qty[row.item_code] = required_qty.get(row.item_code, 0.0) + flt(row.required_qty) + # Duplicate required-item rows each hold the aggregate transferred quantity. + transferred_qty[row.item_code] = max( + transferred_qty.get(row.item_code, 0.0), flt(row.transferred_qty) + ) + return required_qty, transferred_qty + + def _get_covered_work_order_qty(self, required_qty, transferred_qty): + min_fraction = get_minimum_material_coverage_fraction( + required_qty, + transferred_qty, + self.wo_doc.precision("required_qty", "required_items"), + ) + return min_fraction * flt(self.wo_doc.qty) + def validate_component_and_quantities(self): if self.doc.fg_completed_qty: if frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):