From 43de54b9079f9e05567fb02e95e184ffb5d7407d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 12 Aug 2026 16:41:12 +0530 Subject: [PATCH] fix(work order): exclude corrective transfers from item-level transferred qty (#58091) --- .../doctype/job_card/test_job_card.py | 87 +++++++++++++++++++ .../work_order/services/required_items.py | 24 +++++ 2 files changed, 111 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 215ff152389..d3aebcedc98 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -678,6 +678,93 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(corrective_job_card.get_onload("backflush_raw_materials_based_on"), "BOM") self.assertEqual(corrective_job_card.get_onload("transfer_material_against"), "Job Card") + def test_corrective_job_card_transfer_excluded_from_item_transferred_qty(self): + from erpnext.manufacturing.doctype.work_order.mapper import ( + make_stock_entry as make_stock_entry_for_wo, + ) + from erpnext.manufacturing.doctype.work_order.mapper import ( + make_stock_return_entry, + ) + + wo = make_wo_order_test_record( + item="_Test FG Item 2", + qty=4, + transfer_material_against="Work Order", + source_warehouse=self.source_warehouse, + ) + self.generate_required_stock(wo) + + transfer = frappe.get_doc( + make_stock_entry_for_wo(wo.name, "Material Transfer for Manufacture", qty=2) + ) + transfer.fg_completed_qty = 0 + transfer.submit() + + job_card = frappe.get_last_doc("Job Card", {"work_order": wo.name}) + job_card.append( + "time_logs", + {"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 4}, + ) + job_card.submit() + + corrective_operation = frappe.get_doc( + doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash() + ).insert() + corrective_job_card = make_corrective_job_card( + job_card.name, operation=corrective_operation.name, for_operation=job_card.operation + ) + corrective_job_card.for_quantity = 1 + corrective_item = create_item(f"Corrective Item {frappe.generate_hash(length=8)}") + corrective_source_warehouse = wo.required_items[0].source_warehouse + make_stock_entry( + item_code=corrective_item.name, + target=corrective_source_warehouse, + qty=1, + basic_rate=100, + ) + for row in wo.required_items: + corrective_job_card.append( + "items", + { + "item_code": row.item_code, + "source_warehouse": row.source_warehouse, + "uom": frappe.db.get_value("Item", row.item_code, "stock_uom"), + "required_qty": flt(row.required_qty) / 4, + }, + ) + corrective_job_card.append( + "items", + { + "item_code": corrective_item.name, + "source_warehouse": corrective_source_warehouse, + "uom": corrective_item.stock_uom, + "required_qty": 1, + }, + ) + corrective_job_card.insert() + + corrective_transfer = make_stock_entry_from_jc(corrective_job_card.name) + corrective_transfer.submit() + + wo.reload() + self.assertNotIn(corrective_item.name, [row.item_code for row in wo.required_items]) + for row in wo.required_items: + self.assertEqual(flt(row.transferred_qty), flt(row.required_qty) / 2) + + stock_return = make_stock_return_entry(wo.name) + stock_return.company = wo.company + returned_by_item = { + row.item_code: flt(row.transfer_qty) for row in stock_return.items if row.item_code + } + self.assertEqual(returned_by_item[corrective_item.name], 1) + for row in wo.required_items: + self.assertGreater(returned_by_item[row.item_code], flt(row.transferred_qty)) + + stock_return.submit() + wo.reload() + for row in wo.required_items: + self.assertEqual(flt(row.returned_qty), flt(row.transferred_qty)) + @ERPNextTestSuite.change_settings( "Manufacturing Settings", { diff --git a/erpnext/manufacturing/doctype/work_order/services/required_items.py b/erpnext/manufacturing/doctype/work_order/services/required_items.py index 09f0e3ab32c..4fb6bf2b05a 100644 --- a/erpnext/manufacturing/doctype/work_order/services/required_items.py +++ b/erpnext/manufacturing/doctype/work_order/services/required_items.py @@ -293,10 +293,13 @@ class RequiredItemsService: def _material_transfer_qty_by_item(self, is_return): ste = frappe.qb.DocType("Stock Entry") ste_child = frappe.qb.DocType("Stock Entry Detail") + job_card = frappe.qb.DocType("Job Card") query = ( frappe.qb.from_(ste) .inner_join(ste_child) .on(ste_child.parent == ste.name) + .left_join(job_card) + .on(ste.job_card == job_card.name) # original_item becomes the output dict key below, so it must stay coherent per row: the # same item_code can be transferred both for itself (original_item NULL) and as a substitute # for another required item (original_item set). Max() over a single item_code group could @@ -309,14 +312,30 @@ class RequiredItemsService: fn.Sum(ste_child.transfer_qty).as_("qty"), ) .where(self._material_transfer_filter(ste, is_return)) + .where(fn.Coalesce(job_card.is_corrective_job_card, 0) == 0) .groupby(ste_child.item_code, ste_child.original_item) ) qty_by_item = frappe._dict() for d in query.run(as_dict=1) or []: key = d.original_item or d.item_code 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 qty_by_item + def _cap_returned_qty_to_transferred(self, returned_qty_by_item): + # 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) + return frappe._dict( + { + item_code: min(flt(returned_qty), flt(transferred_qty_by_item.get(item_code))) + for item_code, returned_qty in returned_qty_by_item.items() + } + ) + def _material_transfer_filter(self, ste, is_return): return ( (ste.docstatus == 1) @@ -359,6 +378,11 @@ class RequiredItemsService: if stock_entry.purpose != "Material Transfer for Manufacture": return + if stock_entry.job_card and frappe.get_cached_value( + "Job Card", stock_entry.job_card, "is_corrective_job_card" + ): + return + additional_items = self._additional_items_by_code(stock_entry) self.doc.flags.ignore_validate_update_after_submit = True for rows in additional_items.values():