From 5fa68dd06835f77a60ccefa033d7955d83294d3b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 24 Aug 2026 21:38:12 +0530 Subject: [PATCH] fix(manufacturing): preserve job card transfer quantity (#58382) --- .../doctype/job_card/test_job_card.py | 45 ++++++++--- .../stock_entry/services/material_transfer.py | 75 ++++++++++++++++--- 2 files changed, 100 insertions(+), 20 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 2aece0c677c..215e2d4c907 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -327,8 +327,8 @@ class TestJobCard(ERPNextTestSuite): job_card.reload() - self.assertEqual(transfer_entry_1.fg_completed_qty, 2) - self.assertEqual(job_card.transferred_qty, 2) + self.assertEqual(transfer_entry_1.fg_completed_qty, 0) + self.assertEqual(job_card.transferred_qty, 0) # transfer second RM transfer_entry_2 = make_stock_entry_from_jc(job_card_name) @@ -336,9 +336,24 @@ class TestJobCard(ERPNextTestSuite): transfer_entry_2.insert() transfer_entry_2.submit() - # 'For Quantity' here will be 0 since - # transfer was made for 2 fg qty in first transfer Stock Entry - self.assertEqual(transfer_entry_2.fg_completed_qty, 0) + self.assertEqual(transfer_entry_2.fg_completed_qty, 2) + job_card.reload() + self.assertEqual(job_card.transferred_qty, 2) + + def test_job_card_partial_material_transfer_qty(self): + self.transfer_material_against = "Job Card" + self.source_warehouse = "Stores - _TC" + self.generate_required_stock(self.work_order) + + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + transfer_entry = make_stock_entry_from_jc(job_card.name) + for row in transfer_entry.items: + row.qty /= 2 + transfer_entry.submit() + + job_card.reload() + self.assertEqual(transfer_entry.fg_completed_qty, 1) + self.assertEqual(job_card.transferred_qty, 1) @ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1}) def test_job_card_excess_material_transfer(self): @@ -2408,9 +2423,19 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(flt(fg_row.qty), 3.0) me_b.submit() - def make_semi_fg_work_order(self, prefix, qty=5): - """Two-operation semi FG work order: Op A makes the SFG from RM 1, final Op B - consumes it. Both operations skip material transfer; stock is pre-seeded.""" + def test_semi_fg_job_card_transfer_keeps_completed_qty(self): + work_order = self.make_semi_fg_work_order("JC Transfer", skip_material_transfer=False) + job_card = self.get_semi_fg_job_card(work_order, "JC Transfer Op A") + + transfer_entry = make_stock_entry_from_jc(job_card.name) + transfer_entry.submit() + + job_card.reload() + self.assertEqual(transfer_entry.fg_completed_qty, job_card.for_quantity) + self.assertEqual(job_card.transferred_qty, job_card.for_quantity) + + def make_semi_fg_work_order(self, prefix, qty=5, skip_material_transfer=True): + """Create a two-operation semi-finished-goods Work Order with pre-seeded stock.""" from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.stock.doctype.item.test_item import make_item @@ -2443,7 +2468,7 @@ class TestJobCard(ERPNextTestSuite): "time_in_mins": 60, "source_warehouse": warehouse, "fg_warehouse": warehouse, - "skip_material_transfer": 1, + "skip_material_transfer": skip_material_transfer, } operation2 = { "operation": f"{prefix} Op B", @@ -2455,7 +2480,7 @@ class TestJobCard(ERPNextTestSuite): "time_in_mins": 60, "source_warehouse": warehouse, "fg_warehouse": warehouse, - "skip_material_transfer": 1, + "skip_material_transfer": skip_material_transfer, } make_workstation(operation1) make_operation(operation1) diff --git a/erpnext/stock/doctype/stock_entry/services/material_transfer.py b/erpnext/stock/doctype/stock_entry/services/material_transfer.py index 03fe8cff75c..87376b86cb5 100644 --- a/erpnext/stock/doctype/stock_entry/services/material_transfer.py +++ b/erpnext/stock/doctype/stock_entry/services/material_transfer.py @@ -193,17 +193,23 @@ class MaterialTransferForManufactureStockEntry(BaseMaterialTransferStockEntry): if not self._is_overproduction_allowed(flt(self.wo_doc.qty)): return - required_qty, transferred_qty = self._get_work_order_material_qty() + 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.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) + if self.doc.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 row.s_warehouse and material_reference in required_qty: + 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.doc.precision("fg_completed_qty")) self.doc.fg_completed_qty = min(flt(self.doc.fg_completed_qty), covered_by_entry) @@ -214,8 +220,52 @@ class MaterialTransferForManufactureStockEntry(BaseMaterialTransferStockEntry): return False if self.doc.is_return or self.doc.is_additional_transfer_entry: return False + if self.doc.job_card: + return True return not (self.wo_doc.operations and self.wo_doc.transfer_material_against == "Job Card") + def _get_material_coverage_data(self): + if self.doc.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.doc.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 = {} + for row in self.wo_doc.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(self.wo_doc.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 = {} @@ -227,15 +277,20 @@ class MaterialTransferForManufactureStockEntry(BaseMaterialTransferStockEntry): 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.wo_doc.qty, + self.wo_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.wo_doc.precision("required_qty", "required_items"), + precision, ) - return min_fraction * flt(self.wo_doc.qty) + return min_fraction * flt(target_qty) def validate_component_and_quantities(self): if self.doc.fg_completed_qty: