From a22a7fddba07916f796012136ae64db2781c65e1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 18:34:29 +0530 Subject: [PATCH 1/4] fix(job_card): require the previous operation to be manufactured (#57684) * fix(job_card): block next operation until previous operation is manufactured With track semi finished goods, Work Order Operation completed_qty is set from the submitted job cards' total completed qty, so a job card of the next operation could be started and completed even when no Manufacture entry existed for the previous operation. The semi-finished goods it consumes were never produced. Validate the sequence against the qty actually manufactured against the previous operations' job cards (Manufacture entries / Subcontracting Receipts) when the work order tracks semi finished goods. * test(job_card): cover manufactured qty check across previous operations Work order with operations A and B at sequence 1 and C at sequence 2, tracking semi finished goods. C stays blocked while A's job card is submitted but its Manufacture entry is missing, and once A is manufactured for 3, C can only be completed for 3. (cherry picked from commit 3bd33541521d978ca085006091254bb854649859) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py # erpnext/manufacturing/doctype/job_card/test_job_card.py --- .../doctype/job_card/job_card.py | 71 ++++++++- .../doctype/job_card/test_job_card.py | 142 ++++++++++++++++++ 2 files changed, 212 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 3b4f8008f08..b8fa052e801 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1344,18 +1344,58 @@ class JobCard(Document): if data and len(data) > 0: current_operation_qty = flt(data[0].completed_qty) +<<<<<<< HEAD current_operation_qty += flt(self.total_completed_qty) data = frappe.get_all( +======= + for row in self.get_previous_operations(): + if self.track_semi_finished_goods: + self.validate_previous_operation_manufactured_qty(row, current_operation_qty) + else: + self.validate_previous_operation(row, current_operation_qty) + + def get_previous_operations(self): + previous_operations = frappe.get_all( +>>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684)) "Work Order Operation", - fields=["operation", "status", "completed_qty", "sequence_id"], + fields=["name", "operation", "status", "completed_qty", "sequence_id"], filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)}, order_by="sequence_id, idx", ) +<<<<<<< HEAD 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)) ) +======= + if self.track_semi_finished_goods and previous_operations: + manufactured_qty = self.get_manufactured_qty_per_operation( + [row.name for row in previous_operations] + ) + + for row in previous_operations: + row.manufactured_qty = flt(manufactured_qty.get(row.name)) + + return previous_operations + + def get_manufactured_qty_per_operation(self, operation_ids): + job_card = frappe.qb.DocType("Job Card") + + data = ( + frappe.qb.from_(job_card) + .select(job_card.operation_id, Sum(job_card.manufactured_qty)) + .where( + (job_card.work_order == self.work_order) + & (job_card.docstatus == 1) + & (IfNull(job_card.is_corrective_job_card, 0) == 0) + & (job_card.operation_id.isin(operation_ids)) + ) + .groupby(job_card.operation_id) + ).run() + + return dict(data) +>>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684)) for row in data: if not row.completed_qty: @@ -1386,6 +1426,35 @@ class JobCard(Document): ) ) + def validate_previous_operation_manufactured_qty(self, row, current_operation_qty): + manufactured_qty = flt(row.manufactured_qty) + + if not manufactured_qty: + frappe.throw( + _( + "Job Card {0}: As per the sequence of the operations in the work order {1}, submit the manufacturing entry for the operation {2} before the operation {3}." + ).format( + bold(self.name), + bold(get_link_to_form("Work Order", self.work_order)), + bold(row.operation), + bold(self.operation), + ), + OperationSequenceError, + ) + + if manufactured_qty < current_operation_qty: + frappe.throw( + _( + "The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first." + ).format( + bold(current_operation_qty), + bold(self.operation), + bold(manufactured_qty), + bold(row.operation), + ), + OperationSequenceError, + ) + def validate_work_order(self): if self.is_work_order_closed(): frappe.throw(_("You can't make any changes to Job Card since Work Order is closed.")) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 826d558e830..db79f149345 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -10,7 +10,11 @@ from frappe.utils.data import add_to_date, now, today from erpnext.manufacturing.doctype.job_card.job_card import ( JobCardOverTransferError, +<<<<<<< HEAD OperationMismatchError, +======= + OperationSequenceError, +>>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684)) OverlapError, make_corrective_job_card, make_material_request, @@ -1265,6 +1269,144 @@ class TestJobCard(ERPNextTestSuite): 8, ) + 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) + 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 From d97cf131a18f6108133b54e71009d7c50b32a018 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 18:34:30 +0530 Subject: [PATCH 2/4] fix(job_card): apply the completion dialog's qty to manufacture (#57685) * fix(job_card): apply the completion dialog's qty to manufacture Both the desk dialog and the shop floor session dialog send for_quantity when completing a job card, but complete_job_card dropped it. Reducing Qty to Manufacture to 3 on a job card of 5 left for_quantity at 5, so set_process_loss turned the untouched 2 into process loss on the next save. The dialog qty covers the current cycle, so add it to the qty already completed by the earlier cycles of the job card instead of overwriting for_quantity, and validate the pending qty against the result. * test(job_card): cover qty to manufacture from the completion dialog Reducing the dialog qty resizes the job card without inventing process loss, and a pending qty split across two cycles leaves for_quantity untouched. (cherry picked from commit 0e1bc58b2e3078d662612033a8af312e93a7aea0) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py --- .../doctype/job_card/job_card.py | 20 ++++++ .../doctype/job_card/test_job_card.py | 68 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 3b4f8008f08..f28490e0e87 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1514,8 +1514,28 @@ class JobCard(Document): if isinstance(kwargs, dict): kwargs = frappe._dict(kwargs) + self.set_for_quantity(kwargs) self.validate_complete_job_card_qty(kwargs) +<<<<<<< HEAD +======= + self.pending_qty = flt(kwargs.pending_qty) + self.process_loss_qty = flt(kwargs.process_loss_qty) + + self.add_completion_time_logs(kwargs) + + if kwargs.auto_submit: + self.auto_submit_job_card(kwargs.auto_submit) + + def set_for_quantity(self, kwargs): + """Qty to Manufacture of the completion dialog covers the current cycle only, + so the qty completed by the earlier cycles of this job card is kept.""" + if not flt(kwargs.for_quantity): + return + + self.for_quantity = flt(self.total_completed_qty) + flt(kwargs.for_quantity) + +>>>>>>> 0e1bc58b2e (fix(job_card): apply the completion dialog's qty to manufacture (#57685)) def validate_docstatus(self): if self.docstatus == 2: frappe.throw(_("Cancelled Job Card cannot be processed.")) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 826d558e830..98251a93ccc 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -888,6 +888,74 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(wo_doc.process_loss_qty, 2) self.assertEqual(wo_doc.status, "Completed") + def get_first_job_card(self, work_order): + return frappe.get_doc( + "Job Card", + frappe.get_all( + "Job Card", + filters={"work_order": work_order}, + order_by="sequence_id, creation", + limit=1, + pluck="name", + )[0], + ) + + def test_completion_qty_reduces_for_quantity_without_process_loss(self): + work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5) + + job_card = self.get_first_job_card(work_order.name) + job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00"}) + job_card.save() + + job_card.complete_job_card( + qty=3, + for_quantity=3, + pending_qty=0, + process_loss_qty=0, + end_time="2024-03-01 09:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.for_quantity), 3) + self.assertEqual(flt(job_card.total_completed_qty), 3) + self.assertEqual(flt(job_card.process_loss_qty), 0) + + def test_completion_qty_keeps_for_quantity_across_cycles(self): + work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5) + + job_card = self.get_first_job_card(work_order.name) + job_card.append("time_logs", {"from_time": "2024-03-02 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-03-02 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.append("time_logs", {"from_time": "2024-03-02 10:00:00"}) + job_card.save() + + job_card.complete_job_card( + qty=2, + for_quantity=2, + pending_qty=0, + process_loss_qty=0, + end_time="2024-03-02 11:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.for_quantity), 5) + self.assertEqual(flt(job_card.total_completed_qty), 5) + self.assertEqual(flt(job_card.process_loss_qty), 0) + def test_op_cost_calculation(self): from erpnext.manufacturing.doctype.routing.test_routing import ( create_routing, From 7fcfea6db26f99520618b7c09d866324bb7f86ea Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 20:42:02 +0530 Subject: [PATCH 3/4] chore: resolve conflict --- .../doctype/job_card/job_card.py | 55 ++++++++----------- .../doctype/job_card/test_job_card.py | 3 - 2 files changed, 22 insertions(+), 36 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index b8fa052e801..a2708bb5eac 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1344,31 +1344,19 @@ class JobCard(Document): if data and len(data) > 0: current_operation_qty = flt(data[0].completed_qty) -<<<<<<< HEAD current_operation_qty += flt(self.total_completed_qty) - data = frappe.get_all( -======= - for row in self.get_previous_operations(): - if self.track_semi_finished_goods: - self.validate_previous_operation_manufactured_qty(row, current_operation_qty) - else: - self.validate_previous_operation(row, current_operation_qty) - - def get_previous_operations(self): previous_operations = frappe.get_all( ->>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684)) "Work Order Operation", fields=["name", "operation", "status", "completed_qty", "sequence_id"], filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)}, order_by="sequence_id, idx", ) -<<<<<<< HEAD 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)) ) -======= + if self.track_semi_finished_goods and previous_operations: manufactured_qty = self.get_manufactured_qty_per_operation( [row.name for row in previous_operations] @@ -1377,27 +1365,11 @@ class JobCard(Document): for row in previous_operations: row.manufactured_qty = flt(manufactured_qty.get(row.name)) - return previous_operations + for row in previous_operations: + if self.track_semi_finished_goods: + self.validate_previous_operation_manufactured_qty(row, current_operation_qty) + continue - def get_manufactured_qty_per_operation(self, operation_ids): - job_card = frappe.qb.DocType("Job Card") - - data = ( - frappe.qb.from_(job_card) - .select(job_card.operation_id, Sum(job_card.manufactured_qty)) - .where( - (job_card.work_order == self.work_order) - & (job_card.docstatus == 1) - & (IfNull(job_card.is_corrective_job_card, 0) == 0) - & (job_card.operation_id.isin(operation_ids)) - ) - .groupby(job_card.operation_id) - ).run() - - return dict(data) ->>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684)) - - for row in data: if not row.completed_qty: frappe.throw( _("{0}, complete the operation {1} before the operation {2}.").format( @@ -1426,6 +1398,23 @@ class JobCard(Document): ) ) + def get_manufactured_qty_per_operation(self, operation_ids): + job_card = frappe.qb.DocType("Job Card") + + data = ( + frappe.qb.from_(job_card) + .select(job_card.operation_id, Sum(job_card.manufactured_qty)) + .where( + (job_card.work_order == self.work_order) + & (job_card.docstatus == 1) + & (IfNull(job_card.is_corrective_job_card, 0) == 0) + & (job_card.operation_id.isin(operation_ids)) + ) + .groupby(job_card.operation_id) + ).run() + + return dict(data) + 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 db79f149345..4b80d68dd17 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -10,11 +10,8 @@ from frappe.utils.data import add_to_date, now, today from erpnext.manufacturing.doctype.job_card.job_card import ( JobCardOverTransferError, -<<<<<<< HEAD OperationMismatchError, -======= OperationSequenceError, ->>>>>>> 3bd3354152 (fix(job_card): require the previous operation to be manufactured (#57684)) OverlapError, make_corrective_job_card, make_material_request, From f176a4672219f18990e9f8698dae408a04b30c51 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 20:43:30 +0530 Subject: [PATCH 4/4] chore: resolve conflict --- erpnext/manufacturing/doctype/job_card/job_card.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index f28490e0e87..ef71539da71 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1517,16 +1517,6 @@ class JobCard(Document): self.set_for_quantity(kwargs) self.validate_complete_job_card_qty(kwargs) -<<<<<<< HEAD -======= - self.pending_qty = flt(kwargs.pending_qty) - self.process_loss_qty = flt(kwargs.process_loss_qty) - - self.add_completion_time_logs(kwargs) - - if kwargs.auto_submit: - self.auto_submit_job_card(kwargs.auto_submit) - def set_for_quantity(self, kwargs): """Qty to Manufacture of the completion dialog covers the current cycle only, so the qty completed by the earlier cycles of this job card is kept.""" @@ -1535,7 +1525,6 @@ class JobCard(Document): self.for_quantity = flt(self.total_completed_qty) + flt(kwargs.for_quantity) ->>>>>>> 0e1bc58b2e (fix(job_card): apply the completion dialog's qty to manufacture (#57685)) def validate_docstatus(self): if self.docstatus == 2: frappe.throw(_("Cancelled Job Card cannot be processed."))