From 0c7919429e564b3142a5a7fef32098700ae3030a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 18:34:30 +0530 Subject: [PATCH] fix(job_card): leave the pending qty out of the job card's own output (#57686) * fix(job_card): leave the pending qty out of the job card's own output Pending qty is the part of a job card handed over to another job card, but the status and the manufacturing entry still measured the card against its full for_quantity. A card submitted with 3 completed and 2 pending was stuck at Work In Progress with no way to change it, and its manufacturing entry was built for the full 5. Measure both against for_quantity minus pending qty, so the card reaches To Manufacture on submission, its manufacturing entry covers the completed qty, and it is Completed once that qty is manufactured. * test(job_card): cover a job card completed with a pending qty (cherry picked from commit 970039d8ecfca34b255777b69348234b5fdfbdaa) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py # erpnext/manufacturing/doctype/job_card/test_job_card.py --- .../doctype/job_card/job_card.js | 3 +- .../doctype/job_card/job_card.py | 52 ++++ .../doctype/job_card/test_job_card.py | 222 ++++++++++++++++++ 3 files changed, 276 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 0a4026672cf..a19d11adf5d 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -99,7 +99,8 @@ frappe.ui.form.on("Job Card", { doc.docstatus === 1 && !doc.is_subcontracted && (doc.skip_material_transfer || doc.transferred_qty > 0) && - flt(doc.manufactured_qty) + flt(doc.process_loss_qty) < flt(doc.for_quantity); + flt(doc.manufactured_qty) + flt(doc.process_loss_qty) < + flt(doc.for_quantity) - flt(doc.pending_qty); if (!can_make_stock_entry) return; diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 3b4f8008f08..715941dd5e9 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1280,6 +1280,53 @@ class JobCard(Document): if self.workstation: self.update_workstation_status() +<<<<<<< HEAD +======= + def get_qty_to_produce(self): + """Qty this job card is expected to produce, the pending qty is left to another job card.""" + return flt(self.for_quantity) - flt(self.pending_qty) + + def set_finished_good_status(self): + # Only reached for a submitted job card (docstatus == 1) with a finished good, see set_status(). + qty_to_produce = self.get_qty_to_produce() + + if (self.manufactured_qty + self.process_loss_qty) >= qty_to_produce: + self.status = "Completed" + elif (self.total_completed_qty + self.process_loss_qty) >= qty_to_produce: + # Production is done and the card is submitted, but the finished goods have not been + # booked into stock yet (Manufacture Stock Entry pending) — distinct from active WIP. + self.status = "To Manufacture" + elif self.transferred_qty > 0 or self.skip_material_transfer: + self.status = "Work In Progress" + + def set_non_semi_fg_status(self): + if self.items: + item_data = frappe.get_all( + "Job Card Item", + filters={"parent": self.name}, + fields=["transferred_qty", "required_qty"], + ) + all_transferred = item_data and all( + flt(d.transferred_qty) >= flt(d.required_qty) for d in item_data + ) + any_transferred = any(flt(d.transferred_qty) > 0 for d in item_data) + + if all_transferred: + self.status = "Material Transferred" + elif any_transferred: + self.status = "Partially Transferred" + elif flt(self.for_quantity) <= flt(self.transferred_qty): + self.status = "Material Transferred" + + if self.time_logs: + self.status = "Work In Progress" + + if self.docstatus == 1 and ( + self.get_qty_to_produce() <= (self.total_completed_qty + self.process_loss_qty) or not self.items + ): + self.status = "Completed" + +>>>>>>> 970039d8ec (fix(job_card): leave the pending qty out of the job card's own output (#57686)) def set_wip_warehouse(self): if not self.wip_warehouse: self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse") @@ -1578,8 +1625,13 @@ class JobCard(Document): ste = ManufactureEntry( { +<<<<<<< HEAD "for_quantity": self.for_quantity - self.manufactured_qty, "process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0), +======= + "for_quantity": self.get_qty_to_produce() - self.manufactured_qty, + "process_loss_qty": max(self.process_loss_qty - self.get_consumed_process_loss(), 0), +>>>>>>> 970039d8ec (fix(job_card): leave the pending qty out of the job card's own output (#57686)) "job_card": self.name, "skip_material_transfer": self.skip_material_transfer, "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 826d558e830..85c73b4ff1a 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1265,6 +1265,228 @@ class TestJobCard(ERPNextTestSuite): 8, ) +<<<<<<< HEAD +======= + def test_semi_fg_pending_qty_is_left_to_another_job_card(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.stock.doctype.item.test_item import make_item + + warehouse = "Stores - _TC" + rm = make_item("Pending Qty RM 1", {"is_stock_item": 1}).name + fg = make_item("Pending Qty 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": "Pending Qty 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=5, + 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 = self.get_first_job_card(work_order.name) + job_card.append("time_logs", {"from_time": "2024-04-01 08:00:00"}) + job_card.save() + + job_card.complete_job_card( + qty=3, + for_quantity=5, + pending_qty=2, + process_loss_qty=0, + end_time="2024-04-01 09:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.for_quantity), 5) + self.assertEqual(flt(job_card.pending_qty), 2) + self.assertEqual(flt(job_card.process_loss_qty), 0) + + job_card.submit() + self.assertEqual(job_card.status, "To Manufacture") + + manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()) + finished_item = next(row for row in manufacturing_entry.items if row.is_finished_item) + self.assertEqual(flt(finished_item.qty), 3) + manufacturing_entry.submit() + + job_card.reload() + self.assertEqual(flt(job_card.manufactured_qty), 3) + self.assertEqual(job_card.status, "Completed") + + def test_semi_fg_sequence_needs_previous_operations_manufactured(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.stock.doctype.item.test_item import make_item + + warehouse = "Stores - _TC" + rm1 = make_item("Sequence Check RM 1", {"is_stock_item": 1}).name + rm2 = make_item("Sequence Check RM 2", {"is_stock_item": 1}).name + sfg1 = make_item("Sequence Check SFG 1", {"is_stock_item": 1}).name + sfg2 = make_item("Sequence Check SFG 2", {"is_stock_item": 1}).name + fg = make_item("Sequence Check FG 1", {"is_stock_item": 1}).name + + semi_fg_boms = {} + for semi_fg_item, raw_material in ((sfg1, rm1), (sfg2, rm2)): + bom = frappe.new_doc("BOM", company="_Test Company", item=semi_fg_item, quantity=1) + bom.append("items", {"item_code": raw_material, "qty": 1}) + bom.insert() + bom.submit() + semi_fg_boms[semi_fg_item] = bom.name + + fg_bom = frappe.new_doc( + "BOM", + company="_Test Company", + item=fg, + quantity=1, + with_operations=1, + track_semi_finished_goods=1, + ) + + operations = [ + { + "operation": "Sequence Check Op A", + "finished_good": sfg1, + "bom_no": semi_fg_boms[sfg1], + "sequence_id": 1, + }, + { + "operation": "Sequence Check Op B", + "finished_good": sfg2, + "bom_no": semi_fg_boms[sfg2], + "sequence_id": 1, + }, + { + "operation": "Sequence Check Op C", + "finished_good": fg, + "is_final_finished_good": 1, + "sequence_id": 2, + }, + ] + + for row in operations: + row.update( + { + "workstation": "_Test Workstation A", + "finished_good_qty": 1, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + ) + + make_workstation(row) + make_operation(row) + fg_bom.append("operations", row) + + fg_bom.append("items", {"item_code": sfg1, "qty": 1, "operation_row_id": 3}) + fg_bom.append("items", {"item_code": sfg2, "qty": 1, "operation_row_id": 3}) + fg_bom.insert() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg, + qty=5, + source_warehouse=warehouse, + fg_warehouse=warehouse, + bom_no=fg_bom.name, + skip_transfer=1, + do_not_save=True, + ) + + for row in work_order.operations: + row.time_in_mins = 60 + + work_order.save() + work_order.submit() + + make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100) + make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100) + + def get_job_card(operation): + return frappe.get_doc( + "Job Card", + frappe.db.get_value( + "Job Card", + {"work_order": work_order.name, "operation": operation, "docstatus": 0}, + "name", + ), + ) + + def add_time_log(job_card, day, qty): + job_card.append( + "time_logs", + { + "from_time": f"2024-01-{day} 08:00:00", + "to_time": f"2024-01-{day} 09:00:00", + "completed_qty": qty, + }, + ) + + jc_a = get_job_card("Sequence Check Op A") + jc_a.for_quantity = 3 + add_time_log(jc_a, "01", 3) + jc_a.submit() + + jc_b = get_job_card("Sequence Check Op B") + add_time_log(jc_b, "02", jc_b.for_quantity) + jc_b.submit() + frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit() + + jc_c = get_job_card("Sequence Check Op C") + jc_c.for_quantity = 3 + add_time_log(jc_c, "03", 3) + self.assertRaises(OperationSequenceError, jc_c.save) + + frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit() + + jc_c.reload() + jc_c.for_quantity = 4 + add_time_log(jc_c, "03", 4) + self.assertRaises(OperationSequenceError, jc_c.save) + + jc_c.reload() + jc_c.for_quantity = 3 + add_time_log(jc_c, "03", 3) + jc_c.submit() + + self.assertEqual(jc_c.docstatus, 1) + +>>>>>>> 970039d8ec (fix(job_card): leave the pending qty out of the job card's own output (#57686)) 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