From 3497a6a6bf87d5ee2c80ef20a7b7750a06b658fe Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:39:42 +0530 Subject: [PATCH 01/28] fix: require FG / Semi FG Item on operations when tracking semi finished goods A BOM with track_semi_finished_goods enabled could be saved with no finished_good on any operation: validate_semi_finished_goods only checked that one row had 'Is Final Finished Good' set, and a list containing None passed the emptiness check. Such a BOM breaks every downstream step. The work order copies the empty finished_good into its operations, job cards inherit it, and Make Stock Entry finally fails with 'Item None not found' because the manufacture entry has no production item. Derive the finished good where it is unambiguous: an operation that references a BOM produces that BOM's item, and the final operation produces the BOM's own item. Otherwise require it on the row, since each operation's job card books its output through it. --- erpnext/manufacturing/doctype/bom/bom.py | 13 +++++++++++++ .../doctype/bom_operation/bom_operation.json | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 2717da14826..3da64ef08e8 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -346,6 +346,19 @@ class BOM(WebsiteGenerator): fg_items = [] for row in self.operations: + if row.bom_no and not row.finished_good: + row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item") + + if row.is_final_finished_good and not row.finished_good: + row.finished_good = self.item + + if not row.finished_good: + frappe.throw( + _( + "Row #{0}: FG / Semi FG Item is required for the operation {1} as 'Track Semi Finished Goods' is enabled." + ).format(row.idx, bold(row.operation)), + ) + if not row.is_final_finished_good: continue diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index 86fcd7082fd..e6ac3ee474e 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -213,6 +213,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "FG / Semi FG Item", + "mandatory_depends_on": "eval:parent.track_semi_finished_goods === 1", "options": "Item" }, { @@ -307,7 +308,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-05-25 17:15:42.044630", + "modified": "2026-08-08 12:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operation", From aed7c70b1c78a6240b4acb8515d0d5146363d53e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:41:12 +0530 Subject: [PATCH 02/28] test: BOM tracking semi finished goods rejects operations without FG item --- erpnext/manufacturing/doctype/bom/test_bom.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 1ac43b992f6..a2aa4879824 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -919,6 +919,53 @@ class TestBOM(ERPNextTestSuite): for row in bom.items: self.assertEqual(row.stock_uom, "Kg") + @timeout + def test_track_semi_finished_goods_requires_finished_good_on_operations(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation + + fg_item = make_item(properties={"is_stock_item": 1}).name + sfg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + make_workstation({"workstation": "_Test SFG Workstation"}) + for operation in ("_Test SFG Operation", "_Test SFG Final Operation"): + make_operation({"operation": operation, "workstation": "_Test SFG Workstation"}) + + bom = frappe.new_doc("BOM") + bom.company = "_Test Company" + bom.item = fg_item + bom.quantity = 1 + bom.with_operations = 1 + bom.track_semi_finished_goods = 1 + bom.append( + "operations", + { + "operation": "_Test SFG Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + }, + ) + bom.append( + "operations", + { + "operation": "_Test SFG Final Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "is_final_finished_good": 1, + }, + ) + bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1}) + bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2}) + + # the first operation produces nothing derivable: no FG item, no BOM to take it from + self.assertRaises(frappe.ValidationError, bom.insert) + + bom.operations[0].finished_good = sfg_item + bom.insert() + + # the final operation's FG item is derived from the BOM's own item + self.assertEqual(bom.operations[1].finished_good, fg_item) + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) From 6c8f0b9b56778349f02ad14b21c1de8ad557f12a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:41:34 +0530 Subject: [PATCH 03/28] fix: don't demand raw material transfer for semi FG job cards on submit validate_transfer_qty uses an empty finished_good to detect legacy job cards, and unlike validate_semi_finished_goods it ignores skip_material_transfer. A job card tracking semi finished goods whose operation had no finished_good fell into the legacy branch and could not be submitted even with 'Skip Material Transfer' checked on the work order. Return early for semi FG job cards; validate_semi_finished_goods already enforces the transfer requirement for them and honours skip_material_transfer. --- erpnext/manufacturing/doctype/job_card/job_card.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 1d9544d2651..b9b3e93f5ec 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -891,6 +891,9 @@ class JobCard(Document): frappe.msgprint(message, alert=True, indicator="orange") def validate_transfer_qty(self): + if self.track_semi_finished_goods: + return + if ( not self.finished_good and not self.is_corrective_job_card From 4b3904c6d7958469614ff5eede5648a4815563c7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:42:11 +0530 Subject: [PATCH 04/28] test: semi FG job card is exempt from the legacy transfer qty check --- .../manufacturing/doctype/job_card/test_job_card.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index da1b40af076..04e3305de6d 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -2302,3 +2302,15 @@ class TestJobCardLogic(ERPNextTestSuite): self.assertTrue(jc.has_overlap(1, sequential)) self.assertFalse(jc.has_overlap(2, sequential)) self.assertTrue(jc.has_overlap(2, overlapping)) + + def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self): + jc = frappe.new_doc("Job Card") + jc.track_semi_finished_goods = 1 + jc.for_quantity = 10 + jc.transferred_qty = 0 + jc.append("items", {"item_code": "_Test Item"}) + + jc.validate_transfer_qty() + + jc.track_semi_finished_goods = 0 + self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty) From 198eb60df7875d0e4ed300c259ddd1a866c8d418 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:43:22 +0530 Subject: [PATCH 05/28] fix: require WIP warehouse for work orders tracking semi finished goods Work orders with track_semi_finished_goods were exempt from the Work-in-Progress Warehouse requirement in three places: the field's mandatory_depends_on, the fg_warehouse reqd toggle in the form script, and validate_warehouse on submit. The exemption was misleading. The flow still transfers materials to a WIP warehouse when 'Skip Material Transfer' is unchecked: operations default their WIP warehouse from the work order, and set_default_warehouse silently restores the company default after the user clears the field. Make the field genuinely required instead of pretending it is optional. --- erpnext/manufacturing/doctype/work_order/work_order.js | 3 +-- erpnext/manufacturing/doctype/work_order/work_order.json | 4 ++-- erpnext/manufacturing/doctype/work_order/work_order.py | 3 --- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index b540bb7aaa7..b5b69a1018a 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -290,8 +290,7 @@ frappe.ui.form.on("Work Order", { }, set_fg_warehouse_mandatory(frm) { - let mandatory = frm.doc.skip_transfer === 1 || frm.doc.track_semi_finished_goods === 1 ? false : true; - frm.toggle_reqd("fg_warehouse", mandatory); + frm.toggle_reqd("fg_warehouse", frm.doc.skip_transfer !== 1); }, add_custom_button_to_return_components: function (frm) { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 04b970be3e1..cfe140726df 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -272,7 +272,7 @@ "fieldtype": "Link", "label": "Work-in-Progress Warehouse", "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", - "mandatory_depends_on": "eval:(!doc.skip_transfer || doc.from_wip_warehouse) && !doc.track_semi_finished_goods", + "mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse", "options": "Warehouse" }, { @@ -739,7 +739,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2026-06-03 21:35:34.175667", + "modified": "2026-08-08 12:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 8846aae59d0..3e980b716bf 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -601,9 +601,6 @@ class WorkOrder(Document): ) def validate_warehouse(self): - if self.track_semi_finished_goods: - return - if not self.wip_warehouse and not self.skip_transfer: frappe.throw(_("Work-in-Progress Warehouse is required before Submit")) if not self.fg_warehouse: From f61f6523b963ebed361b7f0d66d6514af0069ce1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:44:02 +0530 Subject: [PATCH 06/28] test: WIP warehouse required for work orders tracking semi finished goods --- .../doctype/work_order/test_work_order.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 427a89df811..36121310840 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -5108,6 +5108,17 @@ class TestWorkOrder(ERPNextTestSuite): self.assertEqual(flt(qty_by_item.get(item_a)), 10.0) self.assertEqual(flt(qty_by_item.get(item_b)), 10.0) + def test_wip_warehouse_required_when_tracking_semi_finished_goods(self): + wo = frappe.new_doc("Work Order") + wo.track_semi_finished_goods = 1 + wo.skip_transfer = 0 + wo.fg_warehouse = "_Test Warehouse 1 - _TC" + + self.assertRaises(frappe.ValidationError, wo.validate_warehouse) + + wo.wip_warehouse = "_Test Warehouse - _TC" + wo.validate_warehouse() + def get_reserved_entries(voucher_no, warehouse=None): doctype = frappe.qb.DocType("Stock Reservation Entry") From 1e22695eaef905ad141cb0e3f57fc7513e81c1ce Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:45:08 +0530 Subject: [PATCH 07/28] fix: stop asking for a manufacturing entry when process loss explains the shortfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a previous operation manufactured less than the current job card is completing, the error always said 'Submit the manufacturing entry for the operation first' — even when the entry was already submitted and the missing quantity was booked as process loss, which made the advice a dead end. Sum the process loss of the previous operation's job cards alongside the manufactured quantity. When manufactured + process loss covers the requested quantity, say the shortfall is process loss so the user knows to reduce the completed quantity; keep the submit-first message for genuinely pending manufacturing entries. --- .../doctype/job_card/job_card.py | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index b9b3e93f5ec..f253d2061e6 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1464,12 +1464,12 @@ class JobCard(Document): ) if self.track_semi_finished_goods and previous_operations: - manufactured_qty = self.get_manufactured_qty_per_operation( - [row.name for row in previous_operations] - ) + totals = 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)) + operation_totals = totals.get(row.name) + row.manufactured_qty = flt(operation_totals and operation_totals.manufactured_qty) + row.process_loss_qty = flt(operation_totals and operation_totals.process_loss_qty) return previous_operations @@ -1478,7 +1478,11 @@ class JobCard(Document): data = ( frappe.qb.from_(job_card) - .select(job_card.operation_id, Sum(job_card.manufactured_qty)) + .select( + job_card.operation_id, + Sum(job_card.manufactured_qty).as_("manufactured_qty"), + Sum(job_card.process_loss_qty).as_("process_loss_qty"), + ) .where( (job_card.work_order == self.work_order) & (job_card.docstatus == 1) @@ -1486,9 +1490,9 @@ class JobCard(Document): & (job_card.operation_id.isin(operation_ids)) ) .groupby(job_card.operation_id) - ).run() + ).run(as_dict=True) - return dict(data) + return {row.operation_id: row for row in data} def get_current_operation_completed_qty(self): current_operation_qty = 0.0 @@ -1540,19 +1544,35 @@ class JobCard(Document): OperationSequenceError, ) - if manufactured_qty < current_operation_qty: + if manufactured_qty >= current_operation_qty: + return + + if manufactured_qty + flt(row.process_loss_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." + "The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}, as {4} was booked as process loss there." ).format( bold(self.get_qty_with_uom(current_operation_qty)), bold(self.operation), bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)), bold(row.operation), + bold(self.get_qty_with_uom(flt(row.process_loss_qty), row.finished_good)), ), OperationSequenceError, ) + 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(self.get_qty_with_uom(current_operation_qty)), + bold(self.operation), + bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)), + bold(row.operation), + ), + OperationSequenceError, + ) + def validate_work_order(self): if self.is_work_order_closed(): frappe.throw(_("You cannot make any changes to Job Card since Work Order is closed.")) From 335dbdaca40e2c9639a94648c02feaf7dfd000bb Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:45:43 +0530 Subject: [PATCH 08/28] test: previous operation shortfall from process loss gets the right message --- .../doctype/job_card/test_job_card.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 04e3305de6d..8c2eeaee5f5 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -2303,6 +2303,25 @@ class TestJobCardLogic(ERPNextTestSuite): self.assertFalse(jc.has_overlap(2, sequential)) self.assertTrue(jc.has_overlap(2, overlapping)) + def test_previous_operation_shortfall_from_process_loss_gets_the_right_message(self): + jc = frappe.new_doc("Job Card") + jc.operation = "_Test Painting" + jc.stock_uom = "Nos" + row = frappe._dict( + operation="_Test Assembly", manufactured_qty=8, process_loss_qty=2, finished_good=None + ) + + with self.assertRaises(OperationSequenceError) as loss_error: + jc.validate_previous_operation_manufactured_qty(row, 10) + self.assertIn("process loss", str(loss_error.exception)) + + row.process_loss_qty = 0 + with self.assertRaises(OperationSequenceError) as pending_error: + jc.validate_previous_operation_manufactured_qty(row, 10) + self.assertIn("Submit the manufacturing entry", str(pending_error.exception)) + + jc.validate_previous_operation_manufactured_qty(row, 8) + def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self): jc = frappe.new_doc("Job Card") jc.track_semi_finished_goods = 1 From 1b335973b7c58c623693d7eec68aafca5581de32 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:20:54 +0530 Subject: [PATCH 09/28] fix: scope manufacture entry process loss to its own job card set_process_loss_qty stamped MAX(process_loss_qty) across every operation of the work order onto each manufacture entry. With semi finished goods tracking, one operation's process loss leaked into the entries of every other operation: validate_fg_completed_qty then rejected the entry when it had a BOM, or the wrong loss was recorded silently when it did not, double-counting the loss across operations. When the entry belongs to a job card, use that job card's loss net of what its earlier entries already booked. The MAX fallback stays for work-order level entries without a job card. Fixes frappe/erpnext#57892 --- .../stock/doctype/stock_entry/stock_entry.py | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index b7417eb72e1..707e7ebc461 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1452,23 +1452,15 @@ class StockEntry(StockController, SubcontractingInwardController): return precision = self.precision("process_loss_qty") - if self.work_order: - data = frappe.get_all( - "Work Order Operation", - filters={"parent": self.work_order}, - fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}], + process_loss_qty = self.get_pending_process_loss_qty() + if process_loss_qty and flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision): + self.process_loss_qty = flt(process_loss_qty, precision) + + frappe.msgprint( + _("The Process Loss Qty has been reset as per the job card's Process Loss Qty"), + alert=True, ) - if data and data[0].process_loss_qty: - process_loss_qty = data[0].process_loss_qty - if flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision): - self.process_loss_qty = flt(process_loss_qty, precision) - - frappe.msgprint( - _("The Process Loss Qty has been reset as per the job card's Process Loss Qty"), - alert=True, - ) - if not self.process_loss_percentage and not self.process_loss_qty: self.process_loss_percentage = frappe.get_cached_value( "BOM", self.bom_no, "process_loss_percentage" @@ -1483,6 +1475,23 @@ class StockEntry(StockController, SubcontractingInwardController): (flt(self.process_loss_qty) / flt(self.fg_completed_qty)) * 100 ) + 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).""" + 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) + + if self.work_order: + data = frappe.get_all( + "Work Order Operation", + 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 + + return 0 + def set_work_order_details(self): if self.work_order: # common validations From 5e0f056284cd670438a35cb6c5a1cb34e2c32a08 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:23:28 +0530 Subject: [PATCH 10/28] test: manufacture entry keeps process loss scoped to its own operation --- .../doctype/job_card/test_job_card.py | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 8c2eeaee5f5..fa56430a578 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1726,6 +1726,113 @@ class TestJobCard(ERPNextTestSuite): consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle) self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys())) + def test_manufacture_entry_process_loss_not_taken_from_previous_operation(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("PL Scope RM 1", {"is_stock_item": 1}).name + rm2 = make_item("PL Scope RM 2", {"is_stock_item": 1}).name + sfg = make_item("PL Scope SFG 1", {"is_stock_item": 1}).name + fg1 = make_item("PL Scope FG 1", {"is_stock_item": 1}).name + + sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1) + sfg_bom.append("items", {"item_code": rm1, "qty": 1}) + sfg_bom.insert() + sfg_bom.submit() + + fg_bom = frappe.new_doc( + "BOM", + company="_Test Company", + item=fg1, + quantity=1, + with_operations=1, + track_semi_finished_goods=1, + ) + operation1 = { + "operation": "PL Scope Op A", + "workstation": "_Test Workstation A", + "finished_good": sfg, + "bom_no": sfg_bom.name, + "finished_good_qty": 1, + "sequence_id": 1, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + operation2 = { + "operation": "PL Scope Op B", + "workstation": "_Test Workstation A", + "finished_good": fg1, + "finished_good_qty": 1, + "is_final_finished_good": 1, + "sequence_id": 2, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + make_workstation(operation1) + make_operation(operation1) + make_operation(operation2) + fg_bom.append("operations", operation1) + fg_bom.append("operations", operation2) + fg_bom.append("items", {"item_code": rm2, "qty": 1}) + fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2}) + fg_bom.insert() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg1, + qty=5, + source_warehouse=warehouse, + fg_warehouse=warehouse, + bom_no=fg_bom.name, + skip_transfer=1, + ) + + 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) + make_stock_entry(item_code=sfg, target=warehouse, qty=10, basic_rate=100) + + jc_a = frappe.get_doc( + "Job Card", + frappe.db.get_value( + "Job Card", {"work_order": work_order.name, "operation": "PL Scope Op A"}, "name" + ), + ) + jc_a.append( + "time_logs", + {"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3}, + ) + jc_a.pending_qty = 0 + jc_a.process_loss_qty = 2 + jc_a.submit() + me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()) + me_a.submit() + self.assertEqual(flt(me_a.process_loss_qty), 2.0) + + jc_b = frappe.get_doc( + "Job Card", + frappe.db.get_value( + "Job Card", {"work_order": work_order.name, "operation": "PL Scope Op B"}, "name" + ), + ) + jc_b.append( + "time_logs", + {"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3}, + ) + jc_b.pending_qty = 2 + jc_b.submit() + me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()) + + # operation A's loss must not leak into operation B's entry + self.assertEqual(flt(me_b.process_loss_qty), 0.0) + fg_row = next(row for row in me_b.items if row.is_finished_item) + self.assertEqual(flt(fg_row.qty), 3.0) + me_b.submit() + def test_semi_fg_auto_pull_with_uom_conversion(self): from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.stock.doctype.item.test_item import make_item From 24f1f3dea88d76fa70ef83a4e20b851fbfc32d7a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:27:39 +0530 Subject: [PATCH 11/28] fix: add raw material to its operation even when another operation uses the item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_item_details returns the whole Item document, so the dialog row's name became the item code. get_item_data then matched that item code against every Components row regardless of operation, so adding an item already used by another operation silently updated that row's qty instead of appending one for the target operation — which stayed empty and failed 'please add raw materials or set a BOM' on submit. Match the existing row by item code within the same operation: same operation updates the qty, any other match appends a new row. --- erpnext/manufacturing/doctype/bom/bom.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 3da64ef08e8..f5cd1dc0c21 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -813,15 +813,10 @@ class BOM(WebsiteGenerator): row.update(get_item_details(row.get("item_code"))) row.operation_row_id = operation_row_id - item_row = self.get_item_data(row.name) if row.name else None + item_row = self.get_item_data(row.item_code, operation_row_id) if item_row: - item_row.update( - { - "item_code": row.get("item_code"), - "qty": row.get("qty"), - } - ) + item_row.qty = row.get("qty") else: row.idx = None row.name = None @@ -840,9 +835,9 @@ class BOM(WebsiteGenerator): return False - def get_item_data(self, name): + def get_item_data(self, item_code, operation_row_id): for row in self.items: - if row.item_code == name: + if row.item_code == item_code and cint(row.operation_row_id) == cint(operation_row_id): return row @frappe.whitelist() From 0aec62a8dd5481f482c6c24a4742e0c2059a17b3 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:28:31 +0530 Subject: [PATCH 12/28] test: raw material dialog adds a row for its operation despite duplicates --- erpnext/manufacturing/doctype/bom/test_bom.py | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index a2aa4879824..4d1833a3940 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -7,7 +7,7 @@ from functools import partial import frappe from frappe.tests import timeout -from frappe.utils import cstr, flt +from frappe.utils import cint, cstr, flt from erpnext.controllers.tests.test_subcontracting_controller import ( set_backflush_based_on, @@ -966,6 +966,64 @@ class TestBOM(ERPNextTestSuite): # the final operation's FG item is derived from the BOM's own item self.assertEqual(bom.operations[1].finished_good, fg_item) + @timeout + def test_add_raw_materials_when_item_is_used_by_another_operation(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation + + fg_item = make_item(properties={"is_stock_item": 1}).name + sfg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + make_workstation({"workstation": "_Test SFG Workstation"}) + for operation in ("_Test SFG Operation", "_Test SFG Final Operation"): + make_operation({"operation": operation, "workstation": "_Test SFG Workstation"}) + + bom = frappe.new_doc("BOM") + bom.company = "_Test Company" + bom.item = fg_item + bom.quantity = 1 + bom.with_operations = 1 + bom.track_semi_finished_goods = 1 + bom.append( + "operations", + { + "operation": "_Test SFG Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "finished_good": sfg_item, + }, + ) + bom.append( + "operations", + { + "operation": "_Test SFG Final Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "is_final_finished_good": 1, + }, + ) + bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1}) + bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2}) + bom.insert() + + def rows_for(item_code, operation_row_id): + return [ + row + for row in bom.items + if row.item_code == item_code and cint(row.operation_row_id) == operation_row_id + ] + + # the item already used by operation 1 gets its own new row under operation 2 + bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 3}]) + self.assertEqual(len(rows_for(rm_item, 2)), 1) + self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 3.0) + self.assertEqual(flt(rows_for(rm_item, 1)[0].qty), 1.0) + + # adding it again for the same operation updates the row instead of stacking another + bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 5}]) + self.assertEqual(len(rows_for(rm_item, 2)), 1) + self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 5.0) + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) From 1e2e87daaca9d5df11b732a80699b1ae3b896631 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:58:07 +0530 Subject: [PATCH 13/28] fix: derive operation FG items before material expansion, keep the final one the BOM's item The finished_good derivation ran in validate_semi_finished_goods, after set_materials_based_on_operation_bom had already expanded operation BOM materials. A single-pass insert-and-submit (API or import) with bom_no set but finished_good empty skipped the expansion, persisting a submitted BOM without the referenced components. The derivation also let a final operation inherit another item from its bom_no, so downstream job cards would produce the wrong item. Move the derivation into set_operation_finished_goods, called before the expansion, prefer the BOM's own item for the final operation, and reject a final operation whose FG item is not the BOM's item. --- erpnext/manufacturing/doctype/bom/bom.py | 27 ++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index f5cd1dc0c21..fb4884e33c4 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -314,6 +314,7 @@ class BOM(WebsiteGenerator): self.clear_inspection() self.validate_main_item() self.validate_currency() + self.set_operation_finished_goods() self.set_materials_based_on_operation_bom() self.set_conversion_rate() self.set_plc_conversion_rate() @@ -340,18 +341,25 @@ class BOM(WebsiteGenerator): self.set_fg_cost_allocation() self.validate_total_cost_allocation() + def set_operation_finished_goods(self): + """Fill each operation's FG item where it is unambiguous: the final operation produces + this BOM's item, an operation with a BOM produces that BOM's item. Runs before + set_materials_based_on_operation_bom so derived rows get their materials expanded.""" + if not self.track_semi_finished_goods: + return + + for row in self.operations: + if row.is_final_finished_good and not row.finished_good: + row.finished_good = self.item + elif row.bom_no and not row.finished_good: + row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item") + def validate_semi_finished_goods(self): if not self.track_semi_finished_goods or not self.operations: return fg_items = [] for row in self.operations: - if row.bom_no and not row.finished_good: - row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item") - - if row.is_final_finished_good and not row.finished_good: - row.finished_good = self.item - if not row.finished_good: frappe.throw( _( @@ -362,6 +370,13 @@ class BOM(WebsiteGenerator): if not row.is_final_finished_good: continue + if row.finished_good != self.item: + frappe.throw( + _( + "Row #{0}: The operation {1} has 'Is Final Finished Good' checked, so its FG / Semi FG Item must be {2}." + ).format(row.idx, bold(row.operation), bold(self.item)), + ) + fg_items.append(row.finished_good) if not fg_items: From 9ef386dfd2dd7d225fd0db7689825b468a2e70c1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:01:36 +0530 Subject: [PATCH 14/28] test: operation BOM materials expand on single-pass submit, final FG must match the BOM item --- erpnext/manufacturing/doctype/bom/test_bom.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 4d1833a3940..c61de349a99 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -1024,6 +1024,102 @@ class TestBOM(ERPNextTestSuite): self.assertEqual(len(rows_for(rm_item, 2)), 1) self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 5.0) + @timeout + def test_operation_bom_materials_expand_on_single_pass_submit(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation + + fg_item = make_item(properties={"is_stock_item": 1}).name + sfg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + make_workstation({"workstation": "_Test SFG Workstation"}) + for operation in ("_Test SFG Operation", "_Test SFG Final Operation"): + make_operation({"operation": operation, "workstation": "_Test SFG Workstation"}) + + sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg_item, quantity=1) + sfg_bom.append("items", {"item_code": rm_item, "qty": 1}) + sfg_bom.insert() + sfg_bom.submit() + + bom = frappe.new_doc("BOM") + bom.company = "_Test Company" + bom.item = fg_item + bom.quantity = 1 + bom.with_operations = 1 + bom.track_semi_finished_goods = 1 + bom.append( + "operations", + { + "operation": "_Test SFG Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "bom_no": sfg_bom.name, + }, + ) + bom.append( + "operations", + { + "operation": "_Test SFG Final Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "is_final_finished_good": 1, + }, + ) + bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2}) + bom.submit() + + self.assertEqual(bom.docstatus, 1) + self.assertEqual(bom.operations[0].finished_good, sfg_item) + self.assertTrue( + any(row.item_code == rm_item and cint(row.operation_row_id) == 1 for row in bom.items) + ) + + @timeout + def test_final_operation_must_produce_the_bom_item(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation + + fg_item = make_item(properties={"is_stock_item": 1}).name + sfg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + make_workstation({"workstation": "_Test SFG Workstation"}) + for operation in ("_Test SFG Operation", "_Test SFG Final Operation"): + make_operation({"operation": operation, "workstation": "_Test SFG Workstation"}) + + bom = frappe.new_doc("BOM") + bom.company = "_Test Company" + bom.item = fg_item + bom.quantity = 1 + bom.with_operations = 1 + bom.track_semi_finished_goods = 1 + bom.append( + "operations", + { + "operation": "_Test SFG Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "finished_good": sfg_item, + }, + ) + bom.append( + "operations", + { + "operation": "_Test SFG Final Operation", + "workstation": "_Test SFG Workstation", + "time_in_mins": 30, + "is_final_finished_good": 1, + "finished_good": sfg_item, + }, + ) + bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1}) + bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2}) + + # the final operation claims to produce the semi FG, not this BOM's item + self.assertRaises(frappe.ValidationError, bom.insert) + + bom.operations[1].finished_good = fg_item + bom.insert() + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) From 94cd27ce5daf62e7c8b6022953f5e397ed5d67d0 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:11:20 +0530 Subject: [PATCH 15/28] fix: cap a manufacture entry at the job card's pending production Entries from operations without their own BOM carry no For Quantity, so the finished-good reconciliation cannot run for them and a draft created before other entries were submitted could still over-produce. Validate every job-card manufacture entry against the job card directly: finished goods plus process loss must fit in what the job card still has left to produce after earlier submitted entries. --- .../stock/doctype/stock_entry/stock_entry.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 707e7ebc461..7affec10c7f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -319,6 +319,7 @@ class StockEntry(StockController, SubcontractingInwardController): self.validate_batch() self.validate_inspection() self.validate_fg_completed_qty() + self.validate_job_card_pending_production() self.validate_difference_account() self.validate_job_card_item() self.set_purpose_for_stock_entry() @@ -1475,6 +1476,40 @@ class StockEntry(StockController, SubcontractingInwardController): (flt(self.process_loss_qty) / flt(self.fg_completed_qty)) * 100 ) + def validate_job_card_pending_production(self): + """A draft created before other entries were submitted must not book more than the job + card still has left; without this, a stale draft over-produces the finished good.""" + if self.purpose != "Manufacture" or not self.job_card: + return + + job_card = frappe.get_doc("Job Card", self.job_card) + if job_card.is_corrective_job_card or job_card.is_subcontracted: + return + + precision = frappe.get_precision("Stock Entry Detail", "qty") + pending_qty = flt( + flt(job_card.get_qty_to_produce()) + - flt(job_card.manufactured_qty) + - flt(job_card.get_consumed_process_loss()), + precision, + ) + finished_qty = flt(sum(flt(d.transfer_qty) for d in self.items if d.is_finished_item), precision) + entry_qty = flt(finished_qty + flt(self.process_loss_qty), precision) + + if entry_qty > pending_qty: + uom = job_card.stock_uom + frappe.throw( + _( + "The Job Card {0} has only {1} left to produce, but this entry books {2} ({3} finished goods and {4} process loss). Cancel or update its other manufacture entries first." + ).format( + frappe.bold(self.job_card), + frappe.bold(f"{pending_qty} {uom}"), + frappe.bold(f"{entry_qty} {uom}"), + f"{finished_qty} {uom}", + f"{flt(self.process_loss_qty, precision)} {uom}", + ) + ) + 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).""" From 7157e4357b63d8914e76b1462268e0009aa0a51f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:11:20 +0530 Subject: [PATCH 16/28] test: stale manufacture draft cannot over-produce without an operation BOM --- .../doctype/job_card/test_job_card.py | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index fa56430a578..7933c74819d 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1833,6 +1833,114 @@ 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.""" + 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(f"{prefix} RM 1", {"is_stock_item": 1}).name + rm2 = make_item(f"{prefix} RM 2", {"is_stock_item": 1}).name + sfg = make_item(f"{prefix} SFG 1", {"is_stock_item": 1}).name + fg1 = make_item(f"{prefix} FG 1", {"is_stock_item": 1}).name + + sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1) + sfg_bom.append("items", {"item_code": rm1, "qty": 1}) + sfg_bom.insert() + sfg_bom.submit() + + fg_bom = frappe.new_doc( + "BOM", + company="_Test Company", + item=fg1, + quantity=1, + with_operations=1, + track_semi_finished_goods=1, + ) + operation1 = { + "operation": f"{prefix} Op A", + "workstation": "_Test Workstation A", + "finished_good": sfg, + "bom_no": sfg_bom.name, + "finished_good_qty": 1, + "sequence_id": 1, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + operation2 = { + "operation": f"{prefix} Op B", + "workstation": "_Test Workstation A", + "finished_good": fg1, + "finished_good_qty": 1, + "is_final_finished_good": 1, + "sequence_id": 2, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + make_workstation(operation1) + make_operation(operation1) + make_operation(operation2) + fg_bom.append("operations", operation1) + fg_bom.append("operations", operation2) + fg_bom.append("items", {"item_code": rm2, "qty": 1}) + fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2}) + fg_bom.insert() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg1, + qty=qty, + source_warehouse=warehouse, + fg_warehouse=warehouse, + bom_no=fg_bom.name, + skip_transfer=1, + ) + + for item_code in (rm1, rm2, sfg): + make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=100) + + return work_order + + def get_semi_fg_job_card(self, work_order, operation): + return frappe.get_doc( + "Job Card", + frappe.db.get_value("Job Card", {"work_order": work_order.name, "operation": operation}, "name"), + ) + + def test_stale_manufacture_draft_cannot_over_produce_without_operation_bom(self): + work_order = self.make_semi_fg_work_order("PL NoBom") + + jc_a = self.get_semi_fg_job_card(work_order, "PL NoBom Op A") + jc_a.append( + "time_logs", + {"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5}, + ) + jc_a.submit() + frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit() + + # Op B has no operation BOM, so its entries carry no For Quantity to validate against + jc_b = self.get_semi_fg_job_card(work_order, "PL NoBom Op B") + jc_b.append( + "time_logs", + {"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3}, + ) + jc_b.pending_qty = 0 + jc_b.process_loss_qty = 2 + jc_b.submit() + + draft_one = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()) + draft_two = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()) + + draft_one.submit() + + stale = frappe.get_doc("Stock Entry", draft_two.name) + self.assertRaises(frappe.ValidationError, stale.submit) + def test_semi_fg_auto_pull_with_uom_conversion(self): from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.stock.doctype.item.test_item import make_item From b8dd886cd424e3347a92a7eff4f357758197f1f3 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:25:04 +0530 Subject: [PATCH 17/28] fix: generate the next manufacture entry net of booked process loss After a partial entry booked the job card's full process loss, the next generated entry was sized qty-to-produce minus manufactured only. It exceeded the pending production cap, so Make Stock Entry could not finish the card. Subtract the consumed loss when sizing the entry. --- erpnext/manufacturing/doctype/job_card/job_card.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index f253d2061e6..2b38f72648a 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1824,10 +1824,11 @@ class JobCard(Document): def build_manufacture_stock_entry(self): from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry + consumed_process_loss = self.get_consumed_process_loss() return ManufactureEntry( { - "for_quantity": self.get_qty_to_produce() - self.manufactured_qty, - "process_loss_qty": max(self.process_loss_qty - self.get_consumed_process_loss(), 0), + "for_quantity": self.get_qty_to_produce() - self.manufactured_qty - consumed_process_loss, + "process_loss_qty": max(self.process_loss_qty - consumed_process_loss, 0), "job_card": self.name, "skip_material_transfer": self.skip_material_transfer, "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, From eb7537c8dfab7c7619ba5d450e01601f0513e69a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:25:05 +0530 Subject: [PATCH 18/28] test: partial manufacture entry then finishing the job card --- .../doctype/job_card/test_job_card.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 7933c74819d..bef3f759693 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1912,6 +1912,44 @@ class TestJobCard(ERPNextTestSuite): frappe.db.get_value("Job Card", {"work_order": work_order.name, "operation": operation}, "name"), ) + def test_partial_manufacture_entry_then_finish(self): + work_order = self.make_semi_fg_work_order("PL Partial") + + jc_a = self.get_semi_fg_job_card(work_order, "PL Partial Op A") + jc_a.append( + "time_logs", + {"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5}, + ) + jc_a.submit() + frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit() + + jc_b = self.get_semi_fg_job_card(work_order, "PL Partial Op B") + jc_b.append( + "time_logs", + {"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3}, + ) + jc_b.pending_qty = 0 + jc_b.process_loss_qty = 2 + jc_b.submit() + + # book 1 of the 3 finished units now; the full process loss goes with this first entry + first = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()) + fg_row = next(row for row in first.items if row.is_finished_item) + fg_row.qty = 1 + first.save() + first.submit() + + # the follow-up entry must be generated net of the already-booked loss and still submit + jc_b.reload() + second = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()) + fg_row = next(row for row in second.items if row.is_finished_item) + self.assertEqual(flt(fg_row.qty), 2.0) + self.assertEqual(flt(second.process_loss_qty), 0.0) + second.submit() + + jc_b.reload() + self.assertEqual(flt(jc_b.manufactured_qty), 3.0) + def test_stale_manufacture_draft_cannot_over_produce_without_operation_bom(self): work_order = self.make_semi_fg_work_order("PL NoBom") From 9df527bf3f98ceeac4545b17452d5b9e010d104a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:25:06 +0530 Subject: [PATCH 19/28] fix: keep Target Warehouse optional for work orders tracking semi finished goods The WIP warehouse change also removed the Target Warehouse exemption for semi FG orders, but those may validly carry the target on each operation instead. Restore the exemption in the form and the submit check; the WIP warehouse requirement stays. --- erpnext/manufacturing/doctype/work_order/work_order.js | 3 ++- erpnext/manufacturing/doctype/work_order/work_order.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index b5b69a1018a..b540bb7aaa7 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -290,7 +290,8 @@ frappe.ui.form.on("Work Order", { }, set_fg_warehouse_mandatory(frm) { - frm.toggle_reqd("fg_warehouse", frm.doc.skip_transfer !== 1); + let mandatory = frm.doc.skip_transfer === 1 || frm.doc.track_semi_finished_goods === 1 ? false : true; + frm.toggle_reqd("fg_warehouse", mandatory); }, add_custom_button_to_return_components: function (frm) { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 3e980b716bf..39fc7d16171 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -603,7 +603,7 @@ class WorkOrder(Document): def validate_warehouse(self): if not self.wip_warehouse and not self.skip_transfer: frappe.throw(_("Work-in-Progress Warehouse is required before Submit")) - if not self.fg_warehouse: + if not self.fg_warehouse and not self.track_semi_finished_goods: frappe.throw(_("Target Warehouse is required before Submit")) def before_submit(self): From db99657c470ddf4c0a55b92e2e723ff54c16047d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:25:07 +0530 Subject: [PATCH 20/28] test: target warehouse stays optional for semi FG work orders --- .../manufacturing/doctype/work_order/test_work_order.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 36121310840..8ecdf1e45a7 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -5119,6 +5119,13 @@ class TestWorkOrder(ERPNextTestSuite): wo.wip_warehouse = "_Test Warehouse - _TC" wo.validate_warehouse() + # the top-level target warehouse stays optional; operations may carry their own + wo.fg_warehouse = None + wo.validate_warehouse() + + wo.track_semi_finished_goods = 0 + self.assertRaises(frappe.ValidationError, wo.validate_warehouse) + def get_reserved_entries(voucher_no, warehouse=None): doctype = frappe.qb.DocType("Stock Reservation Entry") From bed957fa677c76b9c1e2adca80aeb5910e9380cc Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:42:28 +0530 Subject: [PATCH 21/28] fix: skip the pending production check on update-after-submit saves Saving a submitted manufacture entry to change an allowed field re-ran the pending production cap with a manufactured aggregate that already includes the entry itself, so the save was rejected against the post-entry remainder. Quantities are not editable after submit, so the check has nothing to protect there. --- erpnext/stock/doctype/stock_entry/stock_entry.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 7affec10c7f..8d798e2c1c6 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1482,6 +1482,9 @@ class StockEntry(StockController, SubcontractingInwardController): if self.purpose != "Manufacture" or not self.job_card: return + if self._action == "update_after_submit": + return + job_card = frappe.get_doc("Job Card", self.job_card) if job_card.is_corrective_job_card or job_card.is_subcontracted: return From 424a1dfa87ffb063498540e10c9199c5cadd5c17 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:42:29 +0530 Subject: [PATCH 22/28] test: update-after-submit save keeps the manufacture entry intact --- .../doctype/job_card/test_job_card.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index bef3f759693..d6576e36377 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1950,6 +1950,31 @@ class TestJobCard(ERPNextTestSuite): jc_b.reload() self.assertEqual(flt(jc_b.manufactured_qty), 3.0) + def test_update_after_submit_keeps_manufacture_entry_intact(self): + work_order = self.make_semi_fg_work_order("PL Update") + + jc_a = self.get_semi_fg_job_card(work_order, "PL Update Op A") + jc_a.append( + "time_logs", + {"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3}, + ) + jc_a.pending_qty = 0 + jc_a.process_loss_qty = 2 + jc_a.submit() + + entry = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()) + entry.submit() + + if not frappe.db.exists("Print Heading", "_Test SFG Heading"): + frappe.get_doc({"doctype": "Print Heading", "print_heading": "_Test SFG Heading"}).insert() + + entry.reload() + entry.select_print_heading = "_Test SFG Heading" + entry.save() + + entry.reload() + self.assertEqual(flt(entry.process_loss_qty), 2.0) + def test_stale_manufacture_draft_cannot_over_produce_without_operation_bom(self): work_order = self.make_semi_fg_work_order("PL NoBom") From 0428cddf5b61a3f71dd105dfe233294730fc9ebe Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:47:07 +0530 Subject: [PATCH 23/28] fix: scale generated raw materials to the manufacture entry's production share Every generated entry copied each Job Card Item's full required_qty in the skip-transfer and BOM-backflush paths, so two entries for one job card consumed the requirement twice. Scale the rows to the share of production this entry accounts for and cap them at the requirement still unconsumed, dropping rows that have nothing left. An entry whose materials are exhausted then fails the existing at-least-one-raw-material check instead of minting finished goods from nothing. --- .../stock_entry_type/stock_entry_type.py | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py index a10441106e0..d9ea63a9f82 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py @@ -126,6 +126,7 @@ class ManufactureEntry: if backflush_based_on != "BOM": available_serial_batches = self.get_transferred_serial_batches() + production_share = self.get_production_share() for item_code, _dict in item_dict.items(): _dict.s_warehouse = self.source_wh.get(item_code) or self.wip_warehouse _dict.t_warehouse = "" @@ -140,11 +141,29 @@ class ManufactureEntry: _dict.qty = calculated_qty self.update_available_serial_batches(_dict, available_serial_batches) - elif self.skip_material_transfer: - set_previous_operation_serial_batch(self.stock_entry, _dict) + else: + remaining_qty = max(flt(_dict.qty) - flt(_dict.consumed_qty), 0) + _dict.qty = min(flt(_dict.qty) * production_share, remaining_qty) + if not _dict.qty: + continue + + if self.skip_material_transfer: + set_previous_operation_serial_batch(self.stock_entry, _dict) self.stock_entry.append("items", _dict) + def get_production_share(self): + """Fraction of the job card's production this entry accounts for; raw materials are + generated proportionally so several partial entries never consume more than required.""" + for_quantity, pending_qty = frappe.db.get_value( + "Job Card", self.job_card, ["for_quantity", "pending_qty"] + ) + qty_to_produce = flt(for_quantity) - flt(pending_qty) + if not qty_to_produce: + return 1 + + return min(flt(self.for_quantity) / qty_to_produce, 1) + def parse_available_serial_batches(self, item_dict, available_serial_batches): key = (item_dict.item_code, item_dict.from_warehouse) if key not in available_serial_batches: From 8f0617c83470d958f79b557d651cdc5fa925bbd2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:47:08 +0530 Subject: [PATCH 24/28] test: partial entries consume exactly the job card's material requirement --- .../doctype/job_card/test_job_card.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index d6576e36377..ed6d7abf62f 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1932,10 +1932,14 @@ class TestJobCard(ERPNextTestSuite): jc_b.process_loss_qty = 2 jc_b.submit() - # book 1 of the 3 finished units now; the full process loss goes with this first entry + # book 1 of the 3 finished units now; the full process loss goes with this first entry, + # so it accounts for 3 of 5 and its materials are trimmed to the same share first = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()) fg_row = next(row for row in first.items if row.is_finished_item) fg_row.qty = 1 + for row in first.items: + if row.s_warehouse and not row.is_finished_item: + row.qty = flt(row.qty) * 3 / 5 first.save() first.submit() @@ -1950,6 +1954,17 @@ class TestJobCard(ERPNextTestSuite): jc_b.reload() self.assertEqual(flt(jc_b.manufactured_qty), 3.0) + # across both entries, consumption adds up to the job card's requirement of 5, no more + consumed = frappe.get_all( + "Stock Entry Detail", + filters={"parent": ["in", [first.name, second.name]], "s_warehouse": ["is", "set"]}, + fields=["item_code", {"SUM": "qty", "as": "qty"}], + group_by="item_code", + ) + self.assertTrue(consumed) + for row in consumed: + self.assertEqual(flt(row.qty), 5.0, f"{row.item_code} mis-consumed across partial entries") + def test_update_after_submit_keeps_manufacture_entry_intact(self): work_order = self.make_semi_fg_work_order("PL Update") From 1deae664ce75cea04c344069d8fee8840cfdff8f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 17:25:34 +0530 Subject: [PATCH 25/28] fix: keep the transfer qty check for legacy semi FG cards without an FG item Existing submitted BOMs may carry operations without a finished good, and no migration repairs them. Exempting every semi FG job card from the transfer check let such a card submit after a partial transfer. Exempt only cards that skip material transfer; legacy cards with transfer enabled keep the strict transferred qty check. --- erpnext/manufacturing/doctype/job_card/job_card.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 2b38f72648a..89f5dbdcac7 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -891,7 +891,7 @@ class JobCard(Document): frappe.msgprint(message, alert=True, indicator="orange") def validate_transfer_qty(self): - if self.track_semi_finished_goods: + if self.track_semi_finished_goods and self.skip_material_transfer: return if ( From 1478e2a4cb24639694d8ac81ee7b6d285f319c64 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 17:25:35 +0530 Subject: [PATCH 26/28] test: transfer qty exemption only applies when material transfer is skipped --- erpnext/manufacturing/doctype/job_card/test_job_card.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index ed6d7abf62f..253df788304 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -2618,11 +2618,20 @@ class TestJobCardLogic(ERPNextTestSuite): def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self): jc = frappe.new_doc("Job Card") jc.track_semi_finished_goods = 1 + jc.skip_material_transfer = 1 jc.for_quantity = 10 jc.transferred_qty = 0 jc.append("items", {"item_code": "_Test Item"}) jc.validate_transfer_qty() + # with transfer enabled, a legacy card without an FG item keeps the strict check + jc.skip_material_transfer = 0 + self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty) + + jc.finished_good = "_Test Item" + jc.validate_transfer_qty() + + jc.finished_good = None jc.track_semi_finished_goods = 0 self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty) From 0eb61c9fac7f685de303288446cb32375c37b02d Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sat, 8 Aug 2026 23:04:28 +0530 Subject: [PATCH 27/28] fix: roll up process loss to the work order for semi finished goods update_work_order_qty() returns early when track_semi_finished_goods is enabled, so set_process_loss_qty() never ran and Work Order.process_loss_qty stayed at zero even though the job cards and the work order operations had booked the loss. The work order also never reached the Completed status, since that needs produced_qty + process_loss_qty to cover the ordered qty. Calling set_process_loss_qty() from that early return is not enough: the final operation has no semi finished good bom, so its manufacture entry is not from a bom, remove_fg_completed_qty() zeroes fg_completed_qty and update_work_order_qty() is never reached at all. The manufacture entries cannot be summed either. Each one is reset to MAX(Work Order Operation.process_loss_qty), so every entry of a multi operation chain carries the running maximum instead of the loss of its own operation. Aggregate the operations instead, and refresh the work order from the job card, which is where the operation loss is written. --- erpnext/manufacturing/doctype/job_card/job_card.py | 3 +++ .../manufacturing/doctype/work_order/services/status.py | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 89f5dbdcac7..1271f1b6117 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1114,6 +1114,9 @@ class JobCard(Document): wo.calculate_operating_cost() wo.set_actual_dates() + if wo.track_semi_finished_goods: + wo.set_process_loss_qty() + if time_data: wo.status = "In Process" diff --git a/erpnext/manufacturing/doctype/work_order/services/status.py b/erpnext/manufacturing/doctype/work_order/services/status.py index eb074a4cc8b..74f204acd40 100644 --- a/erpnext/manufacturing/doctype/work_order/services/status.py +++ b/erpnext/manufacturing/doctype/work_order/services/status.py @@ -291,6 +291,12 @@ class StatusService: ) def set_process_loss_qty(self): + self.doc.db_set("process_loss_qty", self._process_loss_qty()) + + def _process_loss_qty(self): + if self.doc.track_semi_finished_goods: + return flt(sum(flt(row.process_loss_qty) for row in self.doc.operations)) + table = frappe.qb.DocType("Stock Entry") process_loss_qty = ( frappe.qb.from_(table) @@ -302,7 +308,7 @@ class StatusService: ) ).run()[0][0] - self.doc.db_set("process_loss_qty", flt(process_loss_qty)) + return flt(process_loss_qty) def update_production_plan_status(self): production_plan = frappe.get_doc("Production Plan", self.doc.production_plan) From 24de81f9faca2abac250bf580b36e03ea226d11e Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sat, 8 Aug 2026 23:04:28 +0530 Subject: [PATCH 28/28] test: work order process loss for semi finished goods Cover both shapes: a single operation that books the loss itself, and a chain where an earlier operation books it and the final operation loses nothing, so the sum over the operations is the only correct source. --- .../doctype/job_card/test_job_card.py | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 253df788304..ec95b935450 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1447,6 +1447,204 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(flt(job_card.manufactured_qty), 3) self.assertEqual(job_card.status, "Completed") + def test_semi_fg_process_loss_rolls_up_to_work_order(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("Process Loss Rollup RM 1", {"is_stock_item": 1}).name + fg = make_item("Process Loss Rollup 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": "Process Loss Rollup 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=10, + 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-05-01 08:00:00"}) + job_card.save() + + job_card.complete_job_card( + qty=8, + for_quantity=10, + pending_qty=0, + process_loss_qty=2, + end_time="2024-05-01 09:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.process_loss_qty), 2) + + job_card.submit() + frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit() + + self.assertEqual( + flt( + frappe.db.get_value("Work Order Operation", work_order.operations[0].name, "process_loss_qty") + ), + 2, + ) + + work_order.reload() + self.assertEqual(flt(work_order.produced_qty), 8) + self.assertEqual(flt(work_order.process_loss_qty), 2) + self.assertEqual(work_order.status, "Completed") + + def test_semi_fg_process_loss_of_an_intermediate_operation_rolls_up_to_work_order(self): + """Loss booked by an earlier operation shrinks what the final operation can produce, + so it has to show up on the work order even though the final operation loses nothing.""" + 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("Intermediate Loss RM 1", {"is_stock_item": 1}).name + sfg = make_item("Intermediate Loss SFG 1", {"is_stock_item": 1}).name + fg = make_item("Intermediate Loss FG 1", {"is_stock_item": 1}).name + + sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1) + sfg_bom.append("items", {"item_code": rm, "qty": 1}) + sfg_bom.insert() + sfg_bom.submit() + + fg_bom = frappe.new_doc( + "BOM", + company="_Test Company", + item=fg, + quantity=1, + with_operations=1, + track_semi_finished_goods=1, + ) + + operations = [ + { + "operation": "Intermediate Loss Op A", + "finished_good": sfg, + "bom_no": sfg_bom.name, + "sequence_id": 1, + }, + { + "operation": "Intermediate Loss Op B", + "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": sfg, "qty": 1, "operation_row_id": 2}) + fg_bom.insert() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg, + qty=10, + 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=rm, target=warehouse, qty=100, 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", + ), + ) + + jc_a = get_job_card("Intermediate Loss Op A") + jc_a.append("time_logs", {"from_time": "2024-06-01 08:00:00"}) + jc_a.save() + jc_a.complete_job_card( + qty=8, for_quantity=10, pending_qty=0, process_loss_qty=2, end_time="2024-06-01 09:00:00" + ) + jc_a.reload() + jc_a.submit() + frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit() + + work_order.reload() + self.assertEqual(flt(work_order.process_loss_qty), 2) + + # Operation A handed over only 8 units, so the final operation works on 8. + jc_b = get_job_card("Intermediate Loss Op B") + jc_b.for_quantity = 8 + for row in jc_b.items: + row.required_qty = 8 + jc_b.append( + "time_logs", + {"from_time": "2024-06-02 08:00:00", "to_time": "2024-06-02 09:00:00", "completed_qty": 8}, + ) + jc_b.save() + jc_b.submit() + frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit() + + work_order.reload() + self.assertEqual(flt(work_order.produced_qty), 8) + self.assertEqual(flt(work_order.process_loss_qty), 2) + self.assertEqual(work_order.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