From c411b8e47183bf84dd453f8e5878c7cc817eaf41 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 28 Jul 2026 11:04:17 +0530 Subject: [PATCH] fix(manufacturing): sum semi-FG qty across split job cards update_semi_finished_good_details assigned the current job card's manufactured_qty to Work Order.produced_qty instead of accumulating it, so a second job card on the same operation overwrote the first. Nothing corrected it afterwards because StatusService.update_work_order_qty returns early for track_semi_finished_goods work orders, leaving the work order stuck below its planned qty with no way to progress. Aggregate manufactured_qty and completed_qty over the operation's submitted job cards instead. (cherry picked from commit 5548f0726adcda61b5b0eb2eefdaaddd9d30ad79) --- .../doctype/job_card/job_card.py | 36 ++++-- .../doctype/job_card/test_job_card.py | 103 ++++++++++++++++++ 2 files changed, 128 insertions(+), 11 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 27d4212cbd1..981e193f422 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -981,18 +981,32 @@ class JobCard(Document): self.update_work_order_data(for_quantity, process_loss_qty, pending_qty, time_in_mins, wo) def update_semi_finished_good_details(self): - if self.operation_id: - qty = max(flt(self.manufactured_qty), flt(self.total_completed_qty)) + if not self.operation_id: + return - frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", qty) - if ( - self.finished_good - and frappe.get_cached_value("Work Order", self.work_order, "production_item") - == self.finished_good - ): - _wo_doc = frappe.get_doc("Work Order", self.work_order) - _wo_doc.db_set("produced_qty", self.manufactured_qty) - _wo_doc.db_set("status", _wo_doc.get_status()) + job_cards = frappe.get_all( + "Job Card", + filters={ + "work_order": self.work_order, + "operation_id": self.operation_id, + "docstatus": 1, + }, + fields=["manufactured_qty", "total_completed_qty"], + ) + + completed_qty = sum( + max(flt(row.manufactured_qty), flt(row.total_completed_qty)) for row in job_cards + ) + + frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", completed_qty) + if ( + self.finished_good + and frappe.get_cached_value("Work Order", self.work_order, "production_item") + == self.finished_good + ): + _wo_doc = frappe.get_doc("Work Order", self.work_order) + _wo_doc.db_set("produced_qty", sum(flt(row.manufactured_qty) for row in job_cards)) + _wo_doc.db_set("status", _wo_doc.get_status()) def update_corrective_in_work_order(self, wo): wo.corrective_operation_cost = 0.0 diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index f4b3622c9ca..31747de11b3 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1162,6 +1162,109 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(manufacturing_entry.items[2].qty, 9) self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.278) + def test_semi_fg_produced_qty_across_split_job_cards(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.manufacturing.doctype.work_order.mapper import make_job_card + from erpnext.stock.doctype.item.test_item import make_item + + warehouse = "Stores - _TC" + rm = make_item("Split JC RM 1", {"is_stock_item": 1}).name + fg = make_item("Split JC FG 1", {"is_stock_item": 1}).name + + fg_bom = frappe.new_doc( + "BOM", + company="_Test Company", + item=fg, + quantity=1, + with_operations=1, + track_semi_finished_goods=1, + ) + fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1}) + + operation = { + "operation": "Split JC Op A", + "workstation": "_Test Workstation A", + "finished_good": fg, + "finished_good_qty": 1, + "is_final_finished_good": 1, + "sequence_id": 1, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + make_workstation(operation) + make_operation(operation) + fg_bom.append("operations", operation) + fg_bom.insert() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg, + qty=8, + source_warehouse=warehouse, + fg_warehouse=warehouse, + bom_no=fg_bom.name, + skip_transfer=1, + do_not_save=True, + ) + work_order.operations[0].time_in_mins = 60 + work_order.save() + work_order.submit() + + make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100) + + job_card = frappe.get_doc( + "Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name") + ) + job_card.for_quantity = 5 + job_card.append( + "time_logs", + {"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 5}, + ) + job_card.save() + job_card.submit() + frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit() + + work_order.reload() + self.assertEqual(flt(work_order.produced_qty), 5) + + make_job_card( + work_order.name, + [ + { + "name": work_order.operations[0].name, + "operation": "Split JC Op A", + "qty": 3, + "pending_qty": 3, + "skip_material_transfer": 1, + } + ], + ) + + job_card = frappe.get_doc( + "Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name, "docstatus": 0}) + ) + job_card.append( + "time_logs", + { + "from_time": "2024-02-02 08:00:00", + "to_time": "2024-02-02 09:00:00", + "completed_qty": job_card.for_quantity, + }, + ) + job_card.save() + job_card.submit() + frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit() + + work_order.reload() + self.assertEqual(flt(work_order.produced_qty), 8) + self.assertEqual(work_order.status, "Completed") + self.assertEqual( + flt(frappe.db.get_value("Work Order Operation", work_order.operations[0].name, "completed_qty")), + 8, + ) + def test_semi_fg_batch_auto_pull_on_manufacture(self): from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.stock.doctype.item.test_item import make_item