From 76d0cb382d3a95a5233f49f2fde20562c80fdd33 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 27 Aug 2026 17:49:33 +0530 Subject: [PATCH] fix(manufacturing): preserve job card qty in mr transfer (backport #58482) (#58499) --- .../doctype/job_card/test_job_card.py | 38 +++++++++ .../material_request/material_request.py | 8 +- .../stock/doctype/stock_entry/stock_entry.py | 77 ++++++++++++++++--- 3 files changed, 111 insertions(+), 12 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 72ff541e654..da72b2f7298 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -318,6 +318,43 @@ class TestJobCard(ERPNextTestSuite): # transfer was made for 2 fg qty in first transfer Stock Entry self.assertEqual(transfer_entry_2.fg_completed_qty, 0) + def test_material_request_stock_entry_uses_job_card_coverage(self): + from erpnext.stock.doctype.material_request.material_request import make_stock_entry + + self.transfer_material_against = "Job Card" + self.source_warehouse = "Stores - _TC" + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + mr = make_material_request(job_card.name) + mr.schedule_date = today() + for row in mr.items: + row.qty = flt(row.qty) / 2 + row.stock_qty = flt(row.stock_qty) / 2 + mr.submit() + + stock_entry = make_stock_entry(mr.name) + self.assertEqual(stock_entry.fg_completed_qty, job_card.for_quantity / 2) + + selected_row = mr.items[0] + try: + frappe.flags.selected_children = {"items": [selected_row.name]} + selected_stock_entry = make_stock_entry(mr.name) + finally: + frappe.flags.selected_children = None + + self.assertEqual( + [row.job_card_item for row in selected_stock_entry.items], [selected_row.job_card_item] + ) + self.assertEqual(selected_stock_entry.fg_completed_qty, 0) + + for row in mr.items: + transferred_qty = flt(row.stock_qty) / 2 + frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", transferred_qty) + frappe.db.set_value(row.doctype, row.name, "ordered_qty", transferred_qty) + mr.reload() + + repeated_stock_entry = make_stock_entry(mr.name) + self.assertEqual(repeated_stock_entry.fg_completed_qty, job_card.for_quantity / 4) + @ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1}) def test_job_card_excess_material_transfer(self): "Test transferring more than required RM against Job Card." @@ -731,6 +768,7 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(ste.job_card, job_card_name) self.assertEqual(ste.from_bom, 1.0) self.assertEqual(ste.bom_no, work_order.bom_no) + self.assertEqual(ste.fg_completed_qty, frappe.get_value("Job Card", job_card_name, "for_quantity")) def test_job_card_material_transfer_via_pick_list(self): from erpnext.stock.doctype.material_request.material_request import create_pick_list diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index a81eadfefa0..81e185dfaf7 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -984,8 +984,12 @@ def make_stock_entry(source_name: str, target_doc: str | dict | None = None): target.bom_no = work_order_details.bom_no target.use_multi_level_bom = work_order_details.use_multi_level_bom target.from_bom = 1 - # not fg-qty-driven, mirrors the Pick List -> Stock Entry transfer for this Work Order - target.fg_completed_qty = 0 + if not source.job_card: + # not fg-qty-driven, mirrors the Pick List -> Stock Entry transfer for this Work Order + target.fg_completed_qty = 0 + + if source.job_card: + target.cap_completed_qty_to_material_coverage() doclist = get_mapped_doc( "Material Request", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 0403fd65f7d..936b64bbd5c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1317,17 +1317,26 @@ class StockEntry(StockController, SubcontractingInwardController): if transfer_limit_qty < to_transfer_qty: return - required_qty, transferred_qty = self._get_work_order_material_qty() + self.cap_completed_qty_to_material_coverage() + + def cap_completed_qty_to_material_coverage(self): + required_qty, transferred_qty, target_qty, precision = self._get_material_coverage_data() if not required_qty: return - covered_before = self._get_covered_work_order_qty(required_qty, transferred_qty) + covered_before = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision) for row in self.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) + if self.job_card: + material_reference = row.job_card_item + transferred = flt(row.qty) + else: + material_reference = row.original_item or row.item_code + transferred = flt(row.qty) * flt(row.conversion_factor or 1) - covered_after = self._get_covered_work_order_qty(required_qty, transferred_qty) + if material_reference in required_qty and (self.job_card or row.s_warehouse): + transferred_qty[material_reference] += transferred + + covered_after = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision) covered_by_entry = flt(max(covered_after - covered_before, 0), self.precision("fg_completed_qty")) self.fg_completed_qty = min(flt(self.fg_completed_qty), covered_by_entry) @@ -1342,6 +1351,49 @@ class StockEntry(StockController, SubcontractingInwardController): return False return not (self.pro_doc.operations and self.pro_doc.transfer_material_against == "Job Card") + def _get_material_coverage_data(self): + if self.job_card: + return self._get_job_card_material_qty() + return self._get_work_order_material_qty() + + def _get_job_card_material_qty(self): + job_card = frappe.get_doc("Job Card", self.job_card) + required_qty = {} + transferred_qty = {} + for row in job_card.items: + if flt(row.required_qty) <= 0: + continue + required_qty[row.name] = flt(row.required_qty) + transferred_qty[row.name] = flt(row.transferred_qty) + + return ( + required_qty, + transferred_qty, + self._get_job_card_target_qty(job_card), + job_card.precision("required_qty", "items"), + ) + + def _get_job_card_target_qty(self, job_card): + required_by_item = {} + for row in job_card.items: + required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty) + + work_order_required_by_item = {} + work_order = frappe.get_doc("Work Order", job_card.work_order) + for row in work_order.required_items: + if not (job_card.operation == row.operation or job_card.operation_row_id == row.operation_row_id): + continue + work_order_required_by_item[row.item_code] = work_order_required_by_item.get( + row.item_code, 0.0 + ) + flt(row.required_qty) + + target_qty = [ + item_required * flt(work_order.qty) / work_order_required_by_item[item_code] + for item_code, item_required in required_by_item.items() + if work_order_required_by_item.get(item_code) + ] + return min(target_qty) if target_qty else job_card.for_quantity + def _get_work_order_material_qty(self): required_qty = {} transferred_qty = {} @@ -1353,15 +1405,20 @@ class StockEntry(StockController, SubcontractingInwardController): transferred_qty[row.item_code] = max( transferred_qty.get(row.item_code, 0.0), flt(row.transferred_qty) ) - return required_qty, transferred_qty + return ( + required_qty, + transferred_qty, + self.pro_doc.qty, + self.pro_doc.precision("required_qty", "required_items"), + ) - def _get_covered_work_order_qty(self, required_qty, transferred_qty): + def _get_covered_qty(self, required_qty, transferred_qty, target_qty, precision): min_fraction = get_minimum_material_coverage_fraction( required_qty, transferred_qty, - self.pro_doc.precision("required_qty", "required_items"), + precision, ) - return min_fraction * flt(self.pro_doc.qty) + return min_fraction * flt(target_qty) def _validate_no_excess_transfer(self): if self.is_return: