From 27a04d8e08f42cb079c2cfd5a0caba5c40289bf6 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Tue, 18 Aug 2026 15:17:32 +0530 Subject: [PATCH] fix: work order finish dialog with process loss qty from job card (backport #58256) (#58262) * fix(manufacturing): cap job card completed qty by previous operation and show process loss on finish dialog * fix: avoid double booking process loss on partial manufacture entries --- .../doctype/job_card/job_card.py | 44 +++++- .../doctype/job_card/test_job_card.py | 140 ++++++++++++++++++ .../doctype/work_order/work_order.js | 53 +++++-- .../stock/doctype/stock_entry/stock_entry.py | 6 +- 4 files changed, 222 insertions(+), 21 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 0e05bfb18a8..7546c0c8a16 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -150,6 +150,8 @@ class JobCard(Document): self.set_onload("job_card_excess_transfer", excess_transfer) self.set_onload("work_order_closed", self.is_work_order_closed()) self.set_onload("has_stock_entry", self.has_stock_entry()) + if self.docstatus == 0: + self.set_onload("max_completable_qty", self.get_max_completable_qty()) def on_discard(self): self.db_set("status", "Cancelled") @@ -1374,12 +1376,7 @@ class JobCard(Document): current_operation_qty += flt(self.total_completed_qty) - previous_operations = frappe.get_all( - "Work Order Operation", - fields=["name", "operation", "status", "completed_qty", "sequence_id", "finished_good"], - filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)}, - order_by="sequence_id, idx", - ) + previous_operations = self.get_previous_operations() message = "Job Card {}: As per the sequence of the operations in the work order {}".format( bold(self.name), bold(get_link_to_form("Work Order", self.work_order)) @@ -1443,6 +1440,41 @@ class JobCard(Document): return dict(data) + def get_previous_operations(self): + return frappe.get_all( + "Work Order Operation", + fields=["name", "operation", "status", "completed_qty", "sequence_id", "finished_good"], + filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)}, + order_by="sequence_id, idx", + ) + + def get_current_operation_completed_qty(self): + current_operation_qty = 0.0 + data = self.get_current_operation_data() + if data and len(data) > 0: + current_operation_qty = flt(data[0].completed_qty) + + return current_operation_qty + flt(self.total_completed_qty) + + def get_max_completable_qty(self): + if self.is_corrective_job_card or not (self.work_order and self.sequence_id): + return None + + previous_operations = self.get_previous_operations() + if not previous_operations: + return None + + if self.track_semi_finished_goods: + totals = self.get_manufactured_qty_per_operation([row.name for row in previous_operations]) + for row in previous_operations: + row.manufactured_qty = flt(totals.get(row.name)) + + qty_field = "manufactured_qty" if self.track_semi_finished_goods else "completed_qty" + min_completed_qty = min(flt(row.get(qty_field)) for row in previous_operations) + + precision = self.precision("total_completed_qty") + return flt(min_completed_qty - self.get_current_operation_completed_qty(), precision) + def validate_previous_operation_manufactured_qty(self, row, current_operation_qty): manufactured_qty = flt(row.manufactured_qty) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index c12710318bd..1f99ae99482 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -915,6 +915,146 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(wo_doc.process_loss_qty, 2) self.assertEqual(wo_doc.status, "Completed") + def make_two_operation_work_order(self, qty=10): + from erpnext.manufacturing.doctype.routing.test_routing import ( + create_routing, + setup_bom, + setup_operations, + ) + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + operations = [ + {"operation": "Test Operation A1", "workstation": "Test Workstation A", "time_in_mins": 30}, + {"operation": "Test Operation B1", "workstation": "Test Workstation A", "time_in_mins": 20}, + ] + + warehouse = create_warehouse("Test Warehouse 123 for Job Card") + setup_operations(operations) + + item_code = "Test Job Card Process Qty Item" + for item in [item_code, item_code + "RM 1", item_code + "RM 2"]: + if not frappe.db.exists("Item", item): + make_item(item, {"item_name": item, "stock_uom": "Nos", "is_stock_item": 1}) + + routing_doc = create_routing(routing_name="Testing Route", operations=operations) + bom_doc = setup_bom( + item_code=item_code, + routing=routing_doc.name, + raw_materials=[item_code + "RM 1", item_code + "RM 2"], + source_warehouse=warehouse, + ) + + for row in bom_doc.items: + make_stock_entry(item_code=row.item_code, target=row.source_warehouse, qty=qty, basic_rate=100) + + return make_wo_order_test_record( + production_item=item_code, + bom_no=bom_doc.name, + qty=qty, + skip_transfer=1, + wip_warehouse=warehouse, + source_warehouse=warehouse, + ) + + def test_completion_qty_capped_by_previous_operation(self): + wo_doc = self.make_two_operation_work_order() + job_cards = frappe.get_all( + "Job Card", + filters={"work_order": wo_doc.name}, + fields=["name", "sequence_id"], + order_by="sequence_id", + ) + + jc1 = frappe.get_doc("Job Card", job_cards[0].name) + self.assertIsNone(jc1.get_max_completable_qty()) + + jc1.append( + "time_logs", + {"from_time": now(), "to_time": add_to_date(now(), minutes=30), "completed_qty": 8}, + ) + jc1.save() + jc1.submit() + self.assertEqual(jc1.process_loss_qty, 2) + + jc2 = frappe.get_doc("Job Card", job_cards[1].name) + self.assertEqual(jc2.get_max_completable_qty(), 8) + + jc2.append("time_logs", {"from_time": add_to_date(now(), minutes=40)}) + jc2.save() + + self.assertRaises( + frappe.ValidationError, + jc2.complete_job_card, + qty=10, + for_quantity=10, + pending_qty=0, + process_loss_qty=0, + end_time=add_to_date(now(), minutes=70), + ) + + self.complete_second_operation_and_finish(wo_doc, jc2.name) + + def complete_second_operation_and_finish(self, wo_doc, job_card): + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_for_wo, + ) + + jc2 = frappe.get_doc("Job Card", job_card) + jc2.time_logs[0].completed_qty = 7 + jc2.time_logs[0].to_time = add_to_date(now(), minutes=70) + jc2.save() + self.assertEqual(jc2.process_loss_qty, 3) + jc2.submit() + + se = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 10)) + se.submit() + + self.assertEqual(se.process_loss_qty, 3) + fg_qty = sum(d.qty for d in se.items if d.is_finished_item) + self.assertEqual(flt(fg_qty), 7) + + wo_doc.reload() + self.assertEqual(wo_doc.produced_qty, 7) + self.assertEqual(wo_doc.process_loss_qty, 3) + self.assertEqual(wo_doc.status, "Completed") + + def test_process_loss_booked_once_across_partial_entries(self): + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_for_wo, + ) + + wo_doc = self.make_two_operation_work_order() + job_cards = frappe.get_all( + "Job Card", filters={"work_order": wo_doc.name}, fields=["name"], order_by="sequence_id" + ) + + for index, row in enumerate(job_cards): + jc = frappe.get_doc("Job Card", row.name) + from_time = add_to_date(now(), minutes=index * 40) + jc.append( + "time_logs", + {"from_time": from_time, "to_time": add_to_date(from_time, minutes=30), "completed_qty": 7}, + ) + jc.save() + jc.submit() + self.assertEqual(jc.process_loss_qty, 3) + + se1 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 5)) + se1.submit() + self.assertEqual(se1.process_loss_qty, 3) + self.assertEqual(flt(sum(d.qty for d in se1.items if d.is_finished_item)), 2) + + se2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 5)) + se2.submit() + self.assertEqual(flt(se2.process_loss_qty), 0) + self.assertEqual(flt(sum(d.qty for d in se2.items if d.is_finished_item)), 5) + + wo_doc.reload() + self.assertEqual(wo_doc.process_loss_qty, 3) + self.assertEqual(wo_doc.produced_qty, 7) + self.assertEqual(wo_doc.status, "Completed") + def get_first_job_card(self, work_order): return frappe.get_doc( "Job Card", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 04f259f1508..c5ceec37ccc 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -1021,6 +1021,15 @@ erpnext.work_order = { return flt(max, precision("qty")); }, + get_pending_operation_process_loss: (frm) => { + if (!(frm.doc.operations || []).length) { + return 0; + } + + const total_loss = Math.max(...frm.doc.operations.map((row) => flt(row.process_loss_qty))); + return flt(Math.max(total_loss - flt(frm.doc.process_loss_qty), 0), precision("qty")); + }, + show_disassembly_prompt: function (frm) { let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty); @@ -1077,6 +1086,11 @@ erpnext.work_order = { show_prompt_for_qty_input: function (frm, purpose, qty, additional_transfer_entry) { let max = !additional_transfer_entry ? this.get_max_transferable_qty(frm, purpose) : qty; + if (purpose === "Manufacture") { + max = flt(Math.max(max - flt(frm.doc.process_loss_qty), 0), precision("qty")); + } + const pending_process_loss = + purpose === "Manufacture" ? this.get_pending_operation_process_loss(frm) : 0; let fields = [ { @@ -1085,23 +1099,36 @@ erpnext.work_order = { fieldname: "qty", description: __("Max: {0}", [max]), default: max, + onchange: function () { + if (pending_process_loss && frm.qty_prompt) { + frm.qty_prompt.set_value( + "finished_good_qty", + flt(Math.max(flt(this.value) - pending_process_loss, 0), precision("qty")) + ); + } + }, }, ]; - if (!additional_transfer_entry) { - fields.push({ - fieldtype: "Check", - label: __("Consider Process Loss"), - fieldname: "consider_process_loss", - default: 0, - onchange: function () { - if (this.value) { - frm.qty_prompt.set_value("qty", max - frm.doc.process_loss_qty); - } else { - frm.qty_prompt.set_value("qty", max); - } + if (pending_process_loss) { + fields.push( + { + fieldtype: "Float", + label: __("Process Loss Qty"), + fieldname: "process_loss_qty", + default: pending_process_loss, + read_only: 1, + description: __("Process loss booked against the operations of this work order."), }, - }); + { + fieldtype: "Float", + label: __("Finished Good Qty"), + fieldname: "finished_good_qty", + default: flt(Math.max(max - pending_process_loss, 0), precision("qty")), + read_only: 1, + description: __("Actual quantity of the finished good that will be manufactured."), + } + ); } return new Promise((resolve, reject) => { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 604bb994eb3..5779f38869f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3323,7 +3323,7 @@ class StockEntry(StockController, SubcontractingInwardController): def get_pending_process_loss_qty(self): """Loss this entry should still book: the job card's unbooked loss when the entry - belongs to one, else the largest operation loss on the work order (legacy flow).""" + belongs to one, else the unbooked portion of the largest operation loss on the work order.""" if self.job_card: job_card = frappe.get_doc("Job Card", self.job_card) return max(flt(job_card.process_loss_qty) - flt(job_card.get_consumed_process_loss()), 0) @@ -3334,7 +3334,9 @@ class StockEntry(StockController, SubcontractingInwardController): filters={"parent": self.work_order}, fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}], ) - return flt(data[0].process_loss_qty) if data else 0 + max_operation_loss = flt(data[0].process_loss_qty) if data else 0 + booked_loss = flt(frappe.db.get_value("Work Order", self.work_order, "process_loss_qty")) + return max(max_operation_loss - booked_loss, 0) return 0