From 03eeb839fb295570680a76670e7d394060ab89f6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:39:42 +0530 Subject: [PATCH 01/31] 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. (cherry picked from commit 3497a6a6bf87d5ee2c80ef20a7b7750a06b658fe) --- 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 f744a38d9f0..8d607964e91 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -313,6 +313,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 c23b16751ed6eb6fd9682fd1bd1964ad931da6e6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:41:12 +0530 Subject: [PATCH 02/31] test: BOM tracking semi finished goods rejects operations without FG item (cherry picked from commit aed7c70b1c78a6240b4acb8515d0d5146363d53e) --- 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 f40f6bc499e..0c8359264d8 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -811,6 +811,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 99d9d845bd6a1bc332dfa1ca5866087f3268cf1f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:41:34 +0530 Subject: [PATCH 03/31] 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. (cherry picked from commit 6c8f0b9b56778349f02ad14b21c1de8ad557f12a) --- 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 3b4f8008f08..accde70fa97 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -845,6 +845,9 @@ class JobCard(Document): ) 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 87e725e43b5c347f5ce1e1f9587f5b27b7bc0bd4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:42:11 +0530 Subject: [PATCH 04/31] test: semi FG job card is exempt from the legacy transfer qty check (cherry picked from commit 4b3904c6d7958469614ff5eede5648a4815563c7) # Conflicts: # erpnext/manufacturing/doctype/job_card/test_job_card.py --- .../doctype/job_card/test_job_card.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 826d558e830..815f2056c41 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1879,3 +1879,113 @@ def create_semi_fg_bom(semi_fg_item, raw_item, inspection_required): bom.append("items", {"item_code": raw_item, "qty": 1}) bom.submit() return bom.name +<<<<<<< HEAD +======= + + +class TestJobCardLogic(ERPNextTestSuite): + """Field-level validations and pure quantity/capacity helpers, exercised on the + document directly so they don't need a Work Order / BOM (the integration suite does).""" + + def test_processing_a_submitted_or_cancelled_card_is_blocked(self): + submitted = frappe.new_doc("Job Card") + submitted.docstatus = 1 + self.assertRaises(frappe.ValidationError, submitted.validate_docstatus) + + cancelled = frappe.new_doc("Job Card") + cancelled.docstatus = 2 + self.assertRaises(frappe.ValidationError, cancelled.validate_docstatus) + + def test_complete_job_card_qty_guards(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 5 + jc.validate_complete_job_card_qty(frappe._dict(pending_qty=3)) # within range -> passes + self.assertRaises( + frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=-1) + ) + self.assertRaises( + frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(process_loss_qty=-1) + ) + self.assertRaises( + frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=10) + ) + + def test_qty_in_messages_carries_the_uom(self): + jc = frappe.new_doc("Job Card") + jc.stock_uom = "Nos" + + self.assertEqual(jc.get_qty_with_uom(5), "5.0 Nos") + self.assertEqual(jc.get_qty_with_uom(0), "0.0 Nos") + + def test_completion_qty_split_must_add_up(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 5 + + # 3 completed + 2 pending + 0 lost == 5 to manufacture -> passes + jc.validate_complete_job_card_qty( + frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0) + ) + + self.assertRaises( + frappe.ValidationError, + jc.validate_complete_job_card_qty, + frappe._dict(for_quantity=3, qty=3, pending_qty=2, process_loss_qty=0), + ) + + def test_completed_qty_must_reconcile_with_for_quantity(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 10 + jc.total_completed_qty = 6 + jc.process_loss_qty = 0 + jc.pending_qty = 0 + # 6 + 0 + 0 != 10 -> throws + self.assertRaises(frappe.ValidationError, jc.validate_completed_qty_matches_for_quantity) + # completed + loss + pending == for_quantity -> passes + jc.pending_qty = 4 + jc.validate_completed_qty_matches_for_quantity() + + def test_set_process_loss(self): + jc = frappe.new_doc("Job Card") + jc.for_quantity = 10 + jc.total_completed_qty = 6 + jc.pending_qty = 1 + jc.set_process_loss() + self.assertEqual(jc.process_loss_qty, 3) # 10 - 6 - 1 + + # no loss when nothing completed yet + nothing_done = frappe.new_doc("Job Card") + nothing_done.for_quantity = 10 + nothing_done.total_completed_qty = 0 + nothing_done.set_process_loss() + self.assertEqual(nothing_done.process_loss_qty, 0) + + def test_capacity_overlap_detection(self): + jc = frappe.new_doc("Job Card") + sequential = [ + {"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"}, + {"from_time": "2026-01-01 11:00:00", "to_time": "2026-01-01 12:00:00"}, + ] + overlapping = [ + {"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"}, + {"from_time": "2026-01-01 10:30:00", "to_time": "2026-01-01 11:30:00"}, + ] + # sequential logs share one capacity slot; overlapping logs need two + self.assertEqual(len(jc.get_alloted_capacity(sequential)), 1) + self.assertEqual(len(jc.get_alloted_capacity(overlapping)), 2) + # capacity 1 overlaps with any log; capacity 2 only when both slots are taken + 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) +>>>>>>> 4b3904c6d7 (test: semi FG job card is exempt from the legacy transfer qty check) From 24cd5f22b53d7903810649e208bdfe9869eefeee Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:43:22 +0530 Subject: [PATCH 05/31] 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. (cherry picked from commit 198eb60df7875d0e4ed300c259ddd1a866c8d418) --- 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 04f259f1508..9992c2466fd 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -281,8 +281,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 eecf06b15c4..a1fd433ad22 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -915,9 +915,6 @@ class WorkOrder(Document): production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item) 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 e822efe6a11c8af4d854896db4f5b6aea9c5334a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:44:02 +0530 Subject: [PATCH 06/31] test: WIP warehouse required for work orders tracking semi finished goods (cherry picked from commit f61f6523b963ebed361b7f0d66d6514af0069ce1) # Conflicts: # erpnext/manufacturing/doctype/work_order/test_work_order.py --- .../doctype/work_order/test_work_order.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index fb2e49ed571..d58bb078d2a 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -4839,6 +4839,71 @@ class TestWorkOrder(ERPNextTestSuite): # generated qty (3.0 for 8 units) differs from the BOM-scaled qty (7.5 for 20 units) self.assertEqual(flt(row.qty, 6), 3.0) +<<<<<<< HEAD +======= + def test_transferred_qty_not_misattributed_between_item_and_its_substitute(self): + """When one item is transferred both for itself and as a substitute for another required item, + each transfer must be credited to the right required item. + + _material_transfer_qty_by_item grouped Stock Entry Detail by item_code only and picked + Max(original_item); for item B transferred once for itself (original_item NULL) and once as a + substitute for A (original_item=A), Max picked A and credited B's whole transfer to A, leaving + B at 0. Grouping by (item_code, original_item) and accumulating into the keyed dict attributes + each transfer correctly, deterministically on MariaDB and Postgres. + """ + from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService + + source_warehouse = "Stores - _TC" + fg_item = make_item("Test WO SelfSub FG", {"is_stock_item": 1}).name + item_a = make_item("Test WO SelfSub RM A", {"is_stock_item": 1, "allow_alternative_item": 1}).name + item_b = make_item("Test WO SelfSub RM B", {"is_stock_item": 1, "allow_alternative_item": 1}).name + + # B is a registered alternative for A + if not frappe.db.exists("Item Alternative", {"item_code": item_a, "alternative_item_code": item_b}): + frappe.get_doc( + { + "doctype": "Item Alternative", + "item_code": item_a, + "alternative_item_code": item_b, + "two_way": 1, + } + ).insert() + + # stock B generously (covers B-for-A plus B-for-itself) + for item, qty in ((item_a, 50), (item_b, 100)): + test_stock_entry.make_stock_entry( + item_code=item, target=source_warehouse, qty=qty, basic_rate=100 + ) + + make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=[item_a, item_b]) + wo = make_wo_order_test_record(item=fg_item, qty=10, source_warehouse=source_warehouse) + + transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10)) + transfer.save() + # substitute B for the A line; the existing B line stays as B's own transfer + for d in transfer.items: + if d.item_code == item_a: + d.item_code = item_b + d.original_item = item_a + transfer.submit() + + qty_by_item = RequiredItemsService(wo)._material_transfer_qty_by_item(is_return=0) + # B transferred as a substitute for A -> credited to A; B transferred for itself -> credited to B. + 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() + +>>>>>>> f61f6523b9 (test: WIP warehouse required for work orders tracking semi finished goods) def get_reserved_entries(voucher_no, warehouse=None): doctype = frappe.qb.DocType("Stock Reservation Entry") From 104c8df7656cac89834e673192d4b67f82754462 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:45:08 +0530 Subject: [PATCH 07/31] 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. (cherry picked from commit 1e22695eaef905ad141cb0e3f57fc7513e81c1ce) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py --- .../doctype/job_card/job_card.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index accde70fa97..61577623236 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1342,6 +1342,57 @@ class JobCard(Document): if not (self.work_order and self.sequence_id): return +<<<<<<< HEAD +======= + current_operation_qty = self.get_current_operation_completed_qty() + + 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( + "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", + ) + + if self.track_semi_finished_goods and previous_operations: + totals = self.get_manufactured_qty_per_operation([row.name for row in previous_operations]) + + for row in previous_operations: + 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 + + 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).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) + & (IfNull(job_card.is_corrective_job_card, 0) == 0) + & (job_card.operation_id.isin(operation_ids)) + ) + .groupby(job_card.operation_id) + ).run(as_dict=True) + + return {row.operation_id: row for row in data} + + def get_current_operation_completed_qty(self): +>>>>>>> 1e22695eae (fix: stop asking for a manufacturing entry when process loss explains the shortfall) current_operation_qty = 0.0 data = self.get_current_operation_data() if data and len(data) > 0: @@ -1377,6 +1428,7 @@ class JobCard(Document): OperationSequenceError, ) +<<<<<<< HEAD if row.completed_qty < current_operation_qty: frappe.throw( _( @@ -1388,6 +1440,49 @@ class JobCard(Document): bold(row.operation), ) ) +======= + 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: + 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}, 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, + ) +>>>>>>> 1e22695eae (fix: stop asking for a manufacturing entry when process loss explains the shortfall) + + 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(): From f58c0adbf5ec2f5c3e3a3d3d9cd22c0a332697f6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 14:45:43 +0530 Subject: [PATCH 08/31] test: previous operation shortfall from process loss gets the right message (cherry picked from commit 335dbdaca40e2c9639a94648c02feaf7dfd000bb) --- .../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 815f2056c41..b5a7a5c6099 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1977,6 +1977,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 a0b370b2e949548d4e215e80f40e027052711121 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:20:54 +0530 Subject: [PATCH 09/31] 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 (cherry picked from commit 1b335973b7c58c623693d7eec68aafca5581de32) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.py | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 6f8a4644a56..258963ed9c5 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3192,13 +3192,16 @@ 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, ) +<<<<<<< HEAD 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): @@ -3208,6 +3211,8 @@ class StockEntry(StockController, SubcontractingInwardController): _("The Process Loss Qty has reset as per job cards Process Loss Qty"), alert=True ) +======= +>>>>>>> 1b335973b7 (fix: scope manufacture entry process loss to its own job card) 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" @@ -3222,6 +3227,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 not getattr(self, "pro_doc", None): self.pro_doc = frappe._dict() From 0de97159ea06ac4ef61ecd48e1616ea6794c9711 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:23:28 +0530 Subject: [PATCH 10/31] test: manufacture entry keeps process loss scoped to its own operation (cherry picked from commit 5e0f056284cd670438a35cb6c5a1cb34e2c32a08) --- .../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 b5a7a5c6099..f7834c82cfe 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1402,6 +1402,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 35f9bec988611cb2427a9ad1b6b2b792fbacbbdc Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:27:39 +0530 Subject: [PATCH 11/31] 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. (cherry picked from commit 24f1f3dea88d76fa70ef83a4e20b851fbfc32d7a) # Conflicts: # erpnext/manufacturing/doctype/bom/bom.py --- erpnext/manufacturing/doctype/bom/bom.py | 25 ++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 8d607964e91..54fe063fcf0 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -870,6 +870,27 @@ class BOM(WebsiteGenerator): self.save() +<<<<<<< HEAD +======= + def _add_raw_material_row(self, operation_row_id, row): + row = parse_json(row) + + row.update(get_item_details(row.get("item_code"))) + row.operation_row_id = operation_row_id + + item_row = self.get_item_data(row.item_code, operation_row_id) + + if item_row: + item_row.qty = row.get("qty") + else: + row.idx = None + row.name = None + row.do_not_explode = 1 + row.is_sub_assembly_item = self.is_sub_assembly_item(row.item_code) + + self.append("items", row) + +>>>>>>> 24f1f3dea8 (fix: add raw material to its operation even when another operation uses the item) def is_sub_assembly_item(self, item_code): if not self.operations: return False @@ -880,9 +901,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 b4eceeda2d96debbade43a76627db005f1b9265f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:28:31 +0530 Subject: [PATCH 12/31] test: raw material dialog adds a row for its operation despite duplicates (cherry picked from commit 0aec62a8dd5481f482c6c24a4742e0c2059a17b3) --- 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 0c8359264d8..37e73c4dbe6 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, @@ -858,6 +858,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 3e0d0b2d68372a629ffd489882024e9b35736772 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 15:58:07 +0530 Subject: [PATCH 13/31] 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. (cherry picked from commit 1e2e87daaca9d5df11b732a80699b1ae3b896631) # Conflicts: # erpnext/manufacturing/doctype/bom/bom.py --- erpnext/manufacturing/doctype/bom/bom.py | 29 +++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 54fe063fcf0..e33e81d5aaa 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -282,6 +282,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() @@ -304,8 +305,23 @@ class BOM(WebsiteGenerator): self.set_fg_cost_allocation() self.validate_total_cost_allocation() +<<<<<<< HEAD if self.docstatus == 1: self.validate_raw_materials_of_operation() +======= + 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") +>>>>>>> 1e2e87daac (fix: derive operation FG items before material expansion, keep the final one the BOM's item) def validate_semi_finished_goods(self): if not self.track_semi_finished_goods or not self.operations: @@ -313,12 +329,6 @@ 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( _( @@ -329,6 +339,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 605821f04c75d15c620684c04f2ca406e3290dda Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:01:36 +0530 Subject: [PATCH 14/31] test: operation BOM materials expand on single-pass submit, final FG must match the BOM item (cherry picked from commit 9ef386dfd2dd7d225fd0db7689825b468a2e70c1) --- 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 37e73c4dbe6..48eb41fdb11 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -916,6 +916,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 79ad410cc1418cddd0b851a0bb9f04f7714888b1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:11:20 +0530 Subject: [PATCH 15/31] 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. (cherry picked from commit 94cd27ce5daf62e7c8b6022953f5e397ed5d67d0) --- .../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 258963ed9c5..4feabd092c2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -296,6 +296,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.set_job_card_data() self.validate_job_card_item() @@ -3227,6 +3228,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 f25c54e9d7ba2de368b9449ffaa7d0d349119743 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:11:20 +0530 Subject: [PATCH 16/31] test: stale manufacture draft cannot over-produce without an operation BOM (cherry picked from commit 7157e4357b63d8914e76b1462268e0009aa0a51f) --- .../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 f7834c82cfe..89b9dfb159b 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1509,6 +1509,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 2548751673d7853ce5f282bd32fa1746f3019f20 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:25:04 +0530 Subject: [PATCH 17/31] 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. (cherry picked from commit b8dd886cd424e3347a92a7eff4f357758197f1f3) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py --- erpnext/manufacturing/doctype/job_card/job_card.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 61577623236..512de2ee2db 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1674,10 +1674,18 @@ class JobCard(Document): from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry +<<<<<<< HEAD ste = ManufactureEntry( { "for_quantity": self.for_quantity - self.manufactured_qty, "process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0), +======= + consumed_process_loss = self.get_consumed_process_loss() + return ManufactureEntry( + { + "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), +>>>>>>> b8dd886cd4 (fix: generate the next manufacture entry net of booked process loss) "job_card": self.name, "skip_material_transfer": self.skip_material_transfer, "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, From e8859028645e4d51dc02a3c07f4d293878819a93 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:25:05 +0530 Subject: [PATCH 18/31] test: partial manufacture entry then finishing the job card (cherry picked from commit eb7537c8dfab7c7619ba5d450e01601f0513e69a) --- .../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 89b9dfb159b..7160e067653 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1588,6 +1588,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 c598cf9010b41f1549c0b3f2fd2a5c562f0b9fa8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:25:06 +0530 Subject: [PATCH 19/31] 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. (cherry picked from commit 9df527bf3f98ceeac4545b17452d5b9e010d104a) --- 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 9992c2466fd..04f259f1508 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -281,7 +281,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 a1fd433ad22..30ed33a66a4 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -917,7 +917,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 e252329df4d95c1a3082ce5ec9cd747859df3c5a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:25:07 +0530 Subject: [PATCH 20/31] test: target warehouse stays optional for semi FG work orders (cherry picked from commit db99657c470ddf4c0a55b92e2e723ff54c16047d) # Conflicts: # erpnext/manufacturing/doctype/work_order/test_work_order.py --- .../doctype/work_order/test_work_order.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index d58bb078d2a..3e5304180bf 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -4903,7 +4903,17 @@ class TestWorkOrder(ERPNextTestSuite): wo.wip_warehouse = "_Test Warehouse - _TC" wo.validate_warehouse() +<<<<<<< HEAD >>>>>>> f61f6523b9 (test: WIP warehouse required for work orders tracking semi finished goods) +======= + # 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) + +>>>>>>> db99657c47 (test: target warehouse stays optional for semi FG work orders) def get_reserved_entries(voucher_no, warehouse=None): doctype = frappe.qb.DocType("Stock Reservation Entry") From 491f9fa3fe54891744fe3ac45ae488ae179ec363 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:42:28 +0530 Subject: [PATCH 21/31] 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. (cherry picked from commit bed957fa677c76b9c1e2adca80aeb5910e9380cc) --- 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 4feabd092c2..36da536c328 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3234,6 +3234,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 a815a756b770d4401a6b28a4c3efefd22be0e9b0 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:42:29 +0530 Subject: [PATCH 22/31] test: update-after-submit save keeps the manufacture entry intact (cherry picked from commit 424a1dfa87ffb063498540e10c9199c5cadd5c17) --- .../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 7160e067653..f0a1931e689 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1626,6 +1626,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 acba9945b0221d7fa32b714a600e0be0fa845fc5 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:47:07 +0530 Subject: [PATCH 23/31] 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. (cherry picked from commit 0428cddf5b61a3f71dd105dfe233294730fc9ebe) --- .../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 6a809d6f1b2..74996b96a22 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py @@ -125,6 +125,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.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse _dict.to_warehouse = "" @@ -138,11 +139,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.add_to_stock_entry_detail(item_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 5f391a2531c51f29bfbc69052bc408d9425b1176 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 16:47:08 +0530 Subject: [PATCH 24/31] test: partial entries consume exactly the job card's material requirement (cherry picked from commit 8f0617c83470d958f79b557d651cdc5fa925bbd2) --- .../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 f0a1931e689..7aacb300ec2 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1608,10 +1608,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() @@ -1626,6 +1630,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 f810d780c01339174c4e151e422bb44139f05a76 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 17:25:34 +0530 Subject: [PATCH 25/31] 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. (cherry picked from commit 1deae664ce75cea04c344069d8fee8840cfdff8f) --- 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 512de2ee2db..8ed1897d51e 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -845,7 +845,7 @@ class JobCard(Document): ) 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 ae00a09cdf672feb1681121d02e45770a081180f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 17:25:35 +0530 Subject: [PATCH 26/31] test: transfer qty exemption only applies when material transfer is skipped (cherry picked from commit 1478e2a4cb24639694d8ac81ee7b6d285f319c64) --- 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 7aacb300ec2..a0ea71aaab0 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -2292,12 +2292,21 @@ 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) >>>>>>> 4b3904c6d7 (test: semi FG job card is exempt from the legacy transfer qty check) From 27130d8e49f1e283594ab3f7d5d71d48e35740de Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sat, 8 Aug 2026 23:04:28 +0530 Subject: [PATCH 27/31] 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. (cherry picked from commit 0eb61c9fac7f685de303288446cb32375c37b02d) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.py # erpnext/manufacturing/doctype/work_order/services/status.py --- .../doctype/job_card/job_card.py | 25 + .../doctype/work_order/services/status.py | 471 ++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 erpnext/manufacturing/doctype/work_order/services/status.py diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 8ed1897d51e..970f81d5e03 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1041,7 +1041,32 @@ class JobCard(Document): ) def update_work_order_data(self, for_quantity, process_loss_qty, pending_qty, time_in_mins, wo): +<<<<<<< HEAD workstation_hour_rate = frappe.get_value("Workstation", self.workstation, "hour_rate") +======= + time_data = self.get_operation_time_data() + + for data in wo.operations: + if data.get("name") == self.operation_id: + self.update_wo_operation_row( + data, for_quantity, process_loss_qty, pending_qty, time_in_mins, time_data + ) + + wo.flags.ignore_validate_update_after_submit = True + wo.update_operation_status() + 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" + + wo.save() + + def get_operation_time_data(self): +>>>>>>> 0eb61c9fac (fix: roll up process loss to the work order for semi finished goods) jc = frappe.qb.DocType("Job Card") jctl = frappe.qb.DocType("Job Card Time Log") diff --git a/erpnext/manufacturing/doctype/work_order/services/status.py b/erpnext/manufacturing/doctype/work_order/services/status.py new file mode 100644 index 00000000000..74f204acd40 --- /dev/null +++ b/erpnext/manufacturing/doctype/work_order/services/status.py @@ -0,0 +1,471 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Status and quantity-rollup logic for Work Order. + +Extracted from work_order.py. ``StatusService`` wraps a Work Order document +(composition); work_order.py keeps thin delegating stubs so the many external +callers (job cards, sales orders, production plans, patches) keep working. +""" + +import frappe +from frappe import _ +from frappe.query_builder.functions import Sum +from frappe.utils import cint, flt, get_link_to_form + +from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty + +_QTY_PURPOSES = ( + ("Manufacture", "produced_qty"), + ("Material Transfer for Manufacture", "material_transferred_for_manufacturing"), + ("Material Transfer for Manufacture", "additional_transferred_qty"), +) + + +class StatusService: + def __init__(self, doc): + self.doc = doc + + def validate_work_order_against_so(self): + from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError + + total_qty = flt(self._ordered_qty_against_so()) + flt(self.doc.qty) + so_qty = flt(self._so_item_qty()) + flt(self._packed_item_qty()) + allowance_percentage = flt( + frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_sales_order") + ) + if total_qty <= so_qty + (allowance_percentage / 100 * so_qty): + return + + frappe.throw( + _("Cannot produce more Item {0} than Sales Order quantity {1} {2}").format( + get_link_to_form("Item", self.doc.production_item), + frappe.bold(so_qty), + frappe.bold(frappe.get_value("Item", self.doc.production_item, "stock_uom")), + ), + OverProductionError, + ) + + def _ordered_qty_against_so(self): + wo = frappe.qb.DocType("Work Order") + return ( + frappe.qb.from_(wo) + .select(Sum(wo.qty - wo.process_loss_qty)) + .where( + (wo.production_item == self.doc.production_item) + & (wo.sales_order == self.doc.sales_order) + & (wo.docstatus == 1) + & (wo.status != "Closed") + & (wo.name != self.doc.name) + ) + ).run()[0][0] + + def _so_item_qty(self): + so_item = frappe.qb.DocType("Sales Order Item") + return ( + frappe.qb.from_(so_item) + .select(Sum(so_item.stock_qty)) + .where( + (so_item.parent == self.doc.sales_order) + & (so_item.item_code == self.doc.production_item) + & (so_item.docstatus == 1) + ) + ).run()[0][0] + + def _packed_item_qty(self): + packed_item = frappe.qb.DocType("Packed Item") + return ( + frappe.qb.from_(packed_item) + .select(Sum(packed_item.qty)) + .where( + (packed_item.parent == self.doc.sales_order) + & (packed_item.parenttype == "Sales Order") + & (packed_item.item_code == self.doc.production_item) + & (packed_item.docstatus == 1) + ) + ).run()[0][0] + + def update_status(self, status=None): + """Update status of work order if unknown""" + if self.doc.docstatus == 1: + # Refresh material_transferred_for_manufacturing before deciding status so pick-list- + # driven transfers (where this qty is derived from item transfers, not fg_completed_qty) + # are reflected immediately, instead of only after the next status update call. + self.doc.refresh_material_transferred_for_manufacturing() + + if self.doc.status != "Closed": + if status not in ["Stopped", "Closed"]: + status = self.get_status(status) + + if status != self.doc.status: + self.doc.db_set("status", status) + + self.doc.update_required_items() + + return status or self.doc.status + + def get_status(self, status=None): + """Return the status based on stock entries against this work order""" + status = status or self.doc.status + + if self.doc.docstatus == 0: + status = "Draft" + elif self.doc.docstatus == 1: + status = self._submitted_status(status) + else: + status = "Cancelled" + + if self._is_partial_skip_transfer(): + status = "In Process" + + if status != "Completed" and not all(d.status == "Pending" for d in self.doc.operations): + status = "In Process" + + if status == "Not Started" and self.doc.reserve_stock: + status = self._reservation_status(status) + + return status + + def _submitted_status(self, status): + if status in ["Closed", "Stopped"]: + return status + + status = ( + "In Process" + if flt(self.doc.material_transferred_for_manufacturing) > 0 + or self.doc.skip_transfer + or self._has_transferred_material() + else "Not Started" + ) + precision = frappe.get_precision("Work Order", "produced_qty") + total_qty = flt(self.doc.produced_qty, precision) + flt(self.doc.process_loss_qty, precision) + if flt(total_qty, precision) >= flt(self.doc.qty, precision): + status = "Completed" + return status + + def _has_transferred_material(self): + """True if any raw material was transferred against this work order via a pick list + or a material request (these leave material_transferred_for_manufacturing at 0 via + the min-fraction rule).""" + ste = frappe.qb.DocType("Stock Entry") + ste_child = frappe.qb.DocType("Stock Entry Detail") + mr_child = frappe.qb.DocType("Stock Entry Detail") + # Stock Entry only carries `material_request` at the child-row level, so a Stock + # Entry is "MR-sourced" if *any* of its rows link back to a Material Request; once + # that's established, sum every row's transfer_qty, not just the linked ones (a + # manually appended extra row on the same entry has no material_request of its own). + mr_sourced_stock_entries = ( + frappe.qb.from_(mr_child).select(mr_child.parent).where(mr_child.material_request.isnotnull()) + ) + qty = ( + frappe.qb.from_(ste) + .inner_join(ste_child) + .on(ste_child.parent == ste.name) + .select(Sum(ste_child.transfer_qty)) + .where( + (ste.work_order == self.doc.name) + & (ste.docstatus == 1) + & (ste.purpose == "Material Transfer for Manufacture") + & (ste.is_return == 0) + & (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries)) + ) + ).run()[0][0] + return flt(qty) > 0 + + def _is_partial_skip_transfer(self): + return bool( + self.doc.skip_transfer + and self.doc.produced_qty + and self.doc.qty > (flt(self.doc.produced_qty) + flt(self.doc.process_loss_qty)) + ) + + def _reservation_status(self, status): + for row in self.doc.required_items: + if not row.stock_reserved_qty: + continue + + if row.stock_reserved_qty >= row.required_qty: + status = "Stock Reserved" + else: + return "Stock Partially Reserved" + return status + + def update_work_order_qty(self): + """Update Manufactured Qty and Material Transferred for Qty based on Stock Entry""" + if self.doc.track_semi_finished_goods: + return + + for purpose, fieldname in _QTY_PURPOSES: + self._update_qty_for_purpose(purpose, fieldname) + + if self.doc.production_plan: + self.set_produced_qty_for_sub_assembly_item() + self.update_production_plan_status() + + if self.doc.additional_transferred_qty: + self.doc.validate_additional_transferred_qty() + + def _update_qty_for_purpose(self, purpose, fieldname): + from erpnext.manufacturing.doctype.work_order.work_order import StockOverProductionError + + if self._skip_transfer_purpose(purpose): + return + + qty = self.get_transferred_or_manufactured_qty(purpose, fieldname) + completed_qty = self.doc.qty + (self._qty_allowance(purpose) / 100 * self.doc.qty) + if qty > completed_qty: + frappe.throw( + _("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format( + _(self.doc.meta.get_label(fieldname)), qty, completed_qty, self.doc.name + ), + StockOverProductionError, + ) + + self.doc.db_set(fieldname, qty) + self.set_process_loss_qty() + self._update_produced_qty_in_so() + + def _skip_transfer_purpose(self, purpose): + return bool( + purpose == "Material Transfer for Manufacture" + and self.doc.operations + and self.doc.transfer_material_against == "Job Card" + ) + + def _qty_allowance(self, purpose): + allowance = flt( + frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order") + ) + if not allowance and purpose == "Material Transfer for Manufacture": + allowance = flt( + frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage") + ) + return allowance + + def _update_produced_qty_in_so(self): + from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item + + if ( + self.doc.sales_order + and self.doc.sales_order_item + and not self.doc.production_plan_sub_assembly_item + ): + update_produced_qty_in_so_item(self.doc.sales_order, self.doc.sales_order_item) + + def update_disassembled_qty(self, qty, is_cancel=False): + if is_cancel: + self.doc.disassembled_qty = max(0, self.doc.disassembled_qty - qty) + else: + if self.doc.docstatus == 1: + self.doc.disassembled_qty += qty + + if not is_cancel and self.doc.disassembled_qty > self.doc.produced_qty: + frappe.throw(_("Cannot disassemble more than produced quantity.")) + + self.doc.db_set("disassembled_qty", self.doc.disassembled_qty) + + def get_transferred_or_manufactured_qty(self, purpose, fieldname): + parent = frappe.qb.DocType("Stock Entry") + is_additional = cint(fieldname == "additional_transferred_qty") + query = frappe.qb.from_(parent).where(self._stock_entry_filter(parent, purpose, is_additional)) + + if purpose == "Manufacture": + child = frappe.qb.DocType("Stock Entry Detail") + query = ( + query.join(child) + .on(parent.name == child.parent) + .select(Sum(child.transfer_qty)) + .where(child.is_finished_item == 1) + ) + else: + query = query.select(Sum(parent.fg_completed_qty)) + + return flt(query.run()[0][0]) + + def _stock_entry_filter(self, parent, purpose, is_additional): + return ( + (parent.work_order == self.doc.name) + & (parent.docstatus == 1) + & (parent.purpose == purpose) + & (parent.is_additional_transfer_entry == is_additional) + ) + + 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) + .select(Sum(table.process_loss_qty)) + .where( + (table.work_order == self.doc.name) + & (table.purpose == "Manufacture") + & (table.docstatus == 1) + ) + ).run()[0][0] + + return flt(process_loss_qty) + + def update_production_plan_status(self): + production_plan = frappe.get_doc("Production Plan", self.doc.production_plan) + produced_qty = 0 + if self.doc.production_plan_item: + total_qty = frappe.get_all( + "Work Order", + fields=[{"SUM": "produced_qty", "as": "produced_qty"}], + filters={ + "docstatus": 1, + "production_plan": self.doc.production_plan, + "production_plan_item": self.doc.production_plan_item, + }, + as_list=1, + ) + + produced_qty = total_qty[0][0] if total_qty else 0 + + self.update_status() + production_plan.run_method("update_produced_pending_qty", produced_qty, self.doc.production_plan_item) + + def update_planned_qty(self): + if self.doc.track_semi_finished_goods: + return + + update_bin_qty(self.doc.production_item, self.doc.fg_warehouse, self._planned_qty_dict()) + + if self.doc.material_request: + mr_obj = frappe.get_doc("Material Request", self.doc.material_request) + mr_obj.update_requested_qty([self.doc.material_request_item]) + + def _planned_qty_dict(self): + from erpnext.manufacturing.doctype.production_plan.production_plan import ( + get_reserved_qty_for_sub_assembly, + ) + + qty_dict = {"planned_qty": get_planned_qty(self.doc.production_item, self.doc.fg_warehouse)} + if self.doc.production_plan_sub_assembly_item and self.doc.production_plan: + qty_dict["reserved_qty_for_production_plan"] = get_reserved_qty_for_sub_assembly( + self.doc.production_item, self.doc.fg_warehouse + ) + return qty_dict + + def set_produced_qty_for_sub_assembly_item(self): + produced_qty = self._sub_assembly_produced_qty() + frappe.db.set_value( + "Production Plan Sub Assembly Item", + self.doc.production_plan_sub_assembly_item, + "wo_produced_qty", + produced_qty, + ) + + def _sub_assembly_produced_qty(self): + table = frappe.qb.DocType("Work Order") + query = ( + frappe.qb.from_(table) + .select(Sum(table.produced_qty)) + .where( + (table.production_plan == self.doc.production_plan) + & (table.production_plan_sub_assembly_item == self.doc.production_plan_sub_assembly_item) + & (table.docstatus == 1) + ) + ).run() + return flt(query[0][0]) if query else 0 + + def update_ordered_qty(self): + if not ( + self.doc.production_plan + and (self.doc.production_plan_item or self.doc.production_plan_sub_assembly_item) + ): + return + + qty = self._production_plan_ordered_qty() + if self.doc.production_plan_item: + frappe.db.set_value("Production Plan Item", self.doc.production_plan_item, "ordered_qty", qty) + elif self.doc.production_plan_sub_assembly_item: + field = self.doc.production_plan_sub_assembly_item + frappe.db.set_value("Production Plan Sub Assembly Item", field, "ordered_qty", qty) + + doc = frappe.get_doc("Production Plan", self.doc.production_plan) + doc.set_status() + doc.db_set("status", doc.status) + + def _production_plan_ordered_qty(self): + table = frappe.qb.DocType("Work Order") + query = ( + frappe.qb.from_(table) + .select(Sum(table.qty)) + .where((table.production_plan == self.doc.production_plan) & (table.docstatus == 1)) + ) + if self.doc.production_plan_item: + query = query.where(table.production_plan_item == self.doc.production_plan_item) + elif self.doc.production_plan_sub_assembly_item: + query = query.where( + table.production_plan_sub_assembly_item == self.doc.production_plan_sub_assembly_item + ) + + result = query.run() + return flt(result[0][0]) if result else 0 + + def update_work_order_qty_in_so(self): + if ( + not self.doc.sales_order and not self.doc.sales_order_item + ) or self.doc.production_plan_sub_assembly_item: + return + + total_bundle_qty = self._total_bundle_qty() + work_order_qty = self._sales_order_work_order_qty() + frappe.db.set_value( + "Sales Order Item", + self.doc.sales_order_item, + "work_order_qty", + flt(work_order_qty / total_bundle_qty, 2), + ) + + def _sales_order_work_order_qty(self): + wo = frappe.qb.DocType("Work Order") + query = ( + frappe.qb.from_(wo) + .select(Sum(wo.qty)) + .where((wo.sales_order == self.doc.sales_order) & (wo.docstatus == 1) & (wo.status != "Closed")) + ) + if self.doc.product_bundle_item: + query = query.where(wo.product_bundle_item == self.doc.product_bundle_item) + else: + query = query.where(wo.production_item == self.doc.production_item) + + qty = query.run(as_list=1) + return qty[0][0] if qty and qty[0][0] else 0 + + def update_work_order_qty_in_combined_so(self): + total_bundle_qty = self._total_bundle_qty() + prod_plan = frappe.get_doc("Production Plan", self.doc.production_plan) + item_reference = frappe.get_value( + "Production Plan Item", self.doc.production_plan_item, "sales_order_item" + ) + + for plan_reference in prod_plan.prod_plan_references: + if plan_reference.item_reference != item_reference: + continue + + qty = flt(plan_reference.qty) / total_bundle_qty if self.doc.docstatus == 1 else 0.0 + frappe.db.set_value("Sales Order Item", plan_reference.sales_order_item, "work_order_qty", qty) + + def _total_bundle_qty(self): + if not self.doc.product_bundle_item: + return 1 + + pbi = frappe.qb.DocType("Product Bundle Item") + total_bundle_qty = ( + frappe.qb.from_(pbi).select(Sum(pbi.qty)).where(pbi.parent == self.doc.product_bundle_item) + ).run()[0][0] + # product bundle is 0 (product bundle allows 0 qty for items) + return total_bundle_qty or 1 + + def update_completed_qty_in_material_request(self): + if self.doc.material_request and self.doc.material_request_item: + frappe.get_doc("Material Request", self.doc.material_request).update_completed_qty( + [self.doc.material_request_item] + ) From 27625a6f1fe5e0a2c7e70b223e64bfc54118a255 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sat, 8 Aug 2026 23:04:28 +0530 Subject: [PATCH 28/31] 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. (cherry picked from commit 24de81f9faca2abac250bf580b36e03ea226d11e) # Conflicts: # erpnext/manufacturing/doctype/job_card/test_job_card.py --- .../doctype/job_card/test_job_card.py | 420 ++++++++++++++++++ 1 file changed, 420 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index a0ea71aaab0..c1f48953e5b 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1265,6 +1265,426 @@ class TestJobCard(ERPNextTestSuite): 8, ) +<<<<<<< HEAD +======= + def test_semi_fg_pending_qty_is_left_to_another_job_card(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + from erpnext.stock.doctype.item.test_item import make_item + + warehouse = "Stores - _TC" + rm = make_item("Pending Qty RM 1", {"is_stock_item": 1}).name + fg = make_item("Pending Qty FG 1", {"is_stock_item": 1}).name + + fg_bom = frappe.new_doc( + "BOM", + company="_Test Company", + item=fg, + quantity=1, + with_operations=1, + track_semi_finished_goods=1, + ) + fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1}) + + operation = { + "operation": "Pending Qty Op A", + "workstation": "_Test Workstation A", + "finished_good": fg, + "finished_good_qty": 1, + "is_final_finished_good": 1, + "sequence_id": 1, + "time_in_mins": 60, + "source_warehouse": warehouse, + "fg_warehouse": warehouse, + "skip_material_transfer": 1, + } + + make_workstation(operation) + make_operation(operation) + fg_bom.append("operations", operation) + fg_bom.insert() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg, + qty=5, + source_warehouse=warehouse, + fg_warehouse=warehouse, + bom_no=fg_bom.name, + skip_transfer=1, + do_not_save=True, + ) + work_order.operations[0].time_in_mins = 60 + work_order.save() + work_order.submit() + + make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100) + + job_card = self.get_first_job_card(work_order.name) + job_card.append("time_logs", {"from_time": "2024-04-01 08:00:00"}) + job_card.save() + + job_card.complete_job_card( + qty=3, + for_quantity=5, + pending_qty=2, + process_loss_qty=0, + end_time="2024-04-01 09:00:00", + ) + + job_card.reload() + self.assertEqual(flt(job_card.for_quantity), 5) + self.assertEqual(flt(job_card.pending_qty), 2) + self.assertEqual(flt(job_card.process_loss_qty), 0) + + job_card.submit() + self.assertEqual(job_card.status, "To Manufacture") + + manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()) + finished_item = next(row for row in manufacturing_entry.items if row.is_finished_item) + self.assertEqual(flt(finished_item.qty), 3) + manufacturing_entry.submit() + + job_card.reload() + self.assertEqual(flt(job_card.manufactured_qty), 3) + self.assertEqual(job_card.status, "Completed") + + def test_semi_fg_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 + + 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) + +>>>>>>> 24de81f9fa (test: work order process loss for semi finished goods) 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 a5e4bbd4367d8d2472bfc4826430fcea8f98f2fb Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 21:07:17 +0530 Subject: [PATCH 29/31] chore: resolve conflict --- erpnext/manufacturing/doctype/bom/bom.py | 36 +- .../doctype/job_card/job_card.py | 157 +----- .../doctype/job_card/test_job_card.py | 393 ++------------- .../doctype/work_order/services/status.py | 471 ------------------ .../doctype/work_order/test_work_order.py | 57 --- .../doctype/work_order/work_order.py | 8 +- .../stock/doctype/stock_entry/stock_entry.py | 20 +- .../stock_entry_type/stock_entry_type.py | 5 + 8 files changed, 66 insertions(+), 1081 deletions(-) delete mode 100644 erpnext/manufacturing/doctype/work_order/services/status.py diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index e33e81d5aaa..00e754ff561 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -305,10 +305,9 @@ class BOM(WebsiteGenerator): self.set_fg_cost_allocation() self.validate_total_cost_allocation() -<<<<<<< HEAD if self.docstatus == 1: self.validate_raw_materials_of_operation() -======= + 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 @@ -321,7 +320,6 @@ class BOM(WebsiteGenerator): 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") ->>>>>>> 1e2e87daac (fix: derive operation FG items before material expansion, keep the final one the BOM's item) def validate_semi_finished_goods(self): if not self.track_semi_finished_goods or not self.operations: @@ -866,17 +864,10 @@ class BOM(WebsiteGenerator): row.update(get_item_details(row.get("item_code"))) row.operation_row_id = operation_row_id - item_row = None - if row.name: - item_row = self.get_item_data(row.name) + 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 @@ -887,27 +878,6 @@ class BOM(WebsiteGenerator): self.save() -<<<<<<< HEAD -======= - def _add_raw_material_row(self, operation_row_id, row): - row = parse_json(row) - - row.update(get_item_details(row.get("item_code"))) - row.operation_row_id = operation_row_id - - item_row = self.get_item_data(row.item_code, operation_row_id) - - if item_row: - item_row.qty = row.get("qty") - else: - row.idx = None - row.name = None - row.do_not_explode = 1 - row.is_sub_assembly_item = self.is_sub_assembly_item(row.item_code) - - self.append("items", row) - ->>>>>>> 24f1f3dea8 (fix: add raw material to its operation even when another operation uses the item) def is_sub_assembly_item(self, item_code): if not self.operations: return False diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 970f81d5e03..9486ed14094 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1041,32 +1041,7 @@ class JobCard(Document): ) def update_work_order_data(self, for_quantity, process_loss_qty, pending_qty, time_in_mins, wo): -<<<<<<< HEAD workstation_hour_rate = frappe.get_value("Workstation", self.workstation, "hour_rate") -======= - time_data = self.get_operation_time_data() - - for data in wo.operations: - if data.get("name") == self.operation_id: - self.update_wo_operation_row( - data, for_quantity, process_loss_qty, pending_qty, time_in_mins, time_data - ) - - wo.flags.ignore_validate_update_after_submit = True - wo.update_operation_status() - 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" - - wo.save() - - def get_operation_time_data(self): ->>>>>>> 0eb61c9fac (fix: roll up process loss to the work order for semi finished goods) jc = frappe.qb.DocType("Job Card") jctl = frappe.qb.DocType("Job Card Time Log") @@ -1101,6 +1076,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" @@ -1367,57 +1345,6 @@ class JobCard(Document): if not (self.work_order and self.sequence_id): return -<<<<<<< HEAD -======= - current_operation_qty = self.get_current_operation_completed_qty() - - 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( - "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", - ) - - if self.track_semi_finished_goods and previous_operations: - totals = self.get_manufactured_qty_per_operation([row.name for row in previous_operations]) - - for row in previous_operations: - 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 - - 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).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) - & (IfNull(job_card.is_corrective_job_card, 0) == 0) - & (job_card.operation_id.isin(operation_ids)) - ) - .groupby(job_card.operation_id) - ).run(as_dict=True) - - return {row.operation_id: row for row in data} - - def get_current_operation_completed_qty(self): ->>>>>>> 1e22695eae (fix: stop asking for a manufacturing entry when process loss explains the shortfall) current_operation_qty = 0.0 data = self.get_current_operation_data() if data and len(data) > 0: @@ -1453,7 +1380,6 @@ class JobCard(Document): OperationSequenceError, ) -<<<<<<< HEAD if row.completed_qty < current_operation_qty: frappe.throw( _( @@ -1465,49 +1391,6 @@ class JobCard(Document): bold(row.operation), ) ) -======= - 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: - 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}, 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, - ) ->>>>>>> 1e22695eae (fix: stop asking for a manufacturing entry when process loss explains the shortfall) - - 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(): @@ -1684,33 +1567,27 @@ class JobCard(Document): _("Job Card {0} has been completed").format(get_link_to_form("Job Card", self.name)) ) + def get_consumed_process_loss(self): + table = frappe.qb.DocType("Stock Entry") + query = ( + frappe.qb.from_(table) + .select(Sum(table.process_loss_qty)) + .where((table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1)) + ) + return query.run()[0][0] or 0 + @frappe.whitelist() def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False): - def get_consumed_process_loss(): - table = frappe.qb.DocType("Stock Entry") - query = ( - frappe.qb.from_(table) - .select(Sum(table.process_loss_qty)) - .where( - (table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1) - ) - ) - return query.run()[0][0] or 0 - from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry -<<<<<<< HEAD + consumed_process_loss = self.get_consumed_process_loss() ste = ManufactureEntry( { - "for_quantity": self.for_quantity - self.manufactured_qty, - "process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0), -======= - consumed_process_loss = self.get_consumed_process_loss() - return ManufactureEntry( - { - "for_quantity": self.get_qty_to_produce() - self.manufactured_qty - consumed_process_loss, + "for_quantity": self.for_quantity + - self.pending_qty + - self.manufactured_qty + - consumed_process_loss, "process_loss_qty": max(self.process_loss_qty - consumed_process_loss, 0), ->>>>>>> b8dd886cd4 (fix: generate the next manufacture entry net of booked process loss) "job_card": self.name, "skip_material_transfer": self.skip_material_transfer, "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index c1f48953e5b..9fa4b99e6be 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1265,89 +1265,6 @@ class TestJobCard(ERPNextTestSuite): 8, ) -<<<<<<< HEAD -======= - def test_semi_fg_pending_qty_is_left_to_another_job_card(self): - from erpnext.manufacturing.doctype.operation.test_operation import make_operation - from erpnext.stock.doctype.item.test_item import make_item - - warehouse = "Stores - _TC" - rm = make_item("Pending Qty RM 1", {"is_stock_item": 1}).name - fg = make_item("Pending Qty FG 1", {"is_stock_item": 1}).name - - fg_bom = frappe.new_doc( - "BOM", - company="_Test Company", - item=fg, - quantity=1, - with_operations=1, - track_semi_finished_goods=1, - ) - fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1}) - - operation = { - "operation": "Pending Qty Op A", - "workstation": "_Test Workstation A", - "finished_good": fg, - "finished_good_qty": 1, - "is_final_finished_good": 1, - "sequence_id": 1, - "time_in_mins": 60, - "source_warehouse": warehouse, - "fg_warehouse": warehouse, - "skip_material_transfer": 1, - } - - make_workstation(operation) - make_operation(operation) - fg_bom.append("operations", operation) - fg_bom.insert() - fg_bom.submit() - - work_order = make_wo_order_test_record( - item=fg, - qty=5, - source_warehouse=warehouse, - fg_warehouse=warehouse, - bom_no=fg_bom.name, - skip_transfer=1, - do_not_save=True, - ) - work_order.operations[0].time_in_mins = 60 - work_order.save() - work_order.submit() - - make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100) - - job_card = self.get_first_job_card(work_order.name) - job_card.append("time_logs", {"from_time": "2024-04-01 08:00:00"}) - job_card.save() - - job_card.complete_job_card( - qty=3, - for_quantity=5, - pending_qty=2, - process_loss_qty=0, - end_time="2024-04-01 09:00:00", - ) - - job_card.reload() - self.assertEqual(flt(job_card.for_quantity), 5) - self.assertEqual(flt(job_card.pending_qty), 2) - self.assertEqual(flt(job_card.process_loss_qty), 0) - - job_card.submit() - self.assertEqual(job_card.status, "To Manufacture") - - manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()) - finished_item = next(row for row in manufacturing_entry.items if row.is_finished_item) - self.assertEqual(flt(finished_item.qty), 3) - manufacturing_entry.submit() - - job_card.reload() - self.assertEqual(flt(job_card.manufactured_qty), 3) - self.assertEqual(job_card.status, "Completed") - def test_semi_fg_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 @@ -1400,7 +1317,16 @@ class TestJobCard(ERPNextTestSuite): 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 = frappe.get_doc( + "Job Card", + frappe.get_all( + "Job Card", + filters={"work_order": work_order.name}, + order_by="sequence_id, creation", + limit=1, + pluck="name", + )[0], + ) job_card.append("time_logs", {"from_time": "2024-05-01 08:00:00"}) job_card.save() @@ -1528,7 +1454,6 @@ class TestJobCard(ERPNextTestSuite): 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: @@ -1546,145 +1471,6 @@ class TestJobCard(ERPNextTestSuite): 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 - - 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) - ->>>>>>> 24de81f9fa (test: work order process loss for semi finished goods) 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 @@ -2530,6 +2316,27 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(s.additional_costs[2].amount, 480) self.assertEqual(s.additional_costs[3].amount, 480) + 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) + def create_bom_with_multiple_operations(): "Create a BOM with multiple operations and Material Transfer against Job Card" @@ -2592,141 +2399,3 @@ def create_semi_fg_bom(semi_fg_item, raw_item, inspection_required): bom.append("items", {"item_code": raw_item, "qty": 1}) bom.submit() return bom.name -<<<<<<< HEAD -======= - - -class TestJobCardLogic(ERPNextTestSuite): - """Field-level validations and pure quantity/capacity helpers, exercised on the - document directly so they don't need a Work Order / BOM (the integration suite does).""" - - def test_processing_a_submitted_or_cancelled_card_is_blocked(self): - submitted = frappe.new_doc("Job Card") - submitted.docstatus = 1 - self.assertRaises(frappe.ValidationError, submitted.validate_docstatus) - - cancelled = frappe.new_doc("Job Card") - cancelled.docstatus = 2 - self.assertRaises(frappe.ValidationError, cancelled.validate_docstatus) - - def test_complete_job_card_qty_guards(self): - jc = frappe.new_doc("Job Card") - jc.for_quantity = 5 - jc.validate_complete_job_card_qty(frappe._dict(pending_qty=3)) # within range -> passes - self.assertRaises( - frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=-1) - ) - self.assertRaises( - frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(process_loss_qty=-1) - ) - self.assertRaises( - frappe.ValidationError, jc.validate_complete_job_card_qty, frappe._dict(pending_qty=10) - ) - - def test_qty_in_messages_carries_the_uom(self): - jc = frappe.new_doc("Job Card") - jc.stock_uom = "Nos" - - self.assertEqual(jc.get_qty_with_uom(5), "5.0 Nos") - self.assertEqual(jc.get_qty_with_uom(0), "0.0 Nos") - - def test_completion_qty_split_must_add_up(self): - jc = frappe.new_doc("Job Card") - jc.for_quantity = 5 - - # 3 completed + 2 pending + 0 lost == 5 to manufacture -> passes - jc.validate_complete_job_card_qty( - frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0) - ) - - self.assertRaises( - frappe.ValidationError, - jc.validate_complete_job_card_qty, - frappe._dict(for_quantity=3, qty=3, pending_qty=2, process_loss_qty=0), - ) - - def test_completed_qty_must_reconcile_with_for_quantity(self): - jc = frappe.new_doc("Job Card") - jc.for_quantity = 10 - jc.total_completed_qty = 6 - jc.process_loss_qty = 0 - jc.pending_qty = 0 - # 6 + 0 + 0 != 10 -> throws - self.assertRaises(frappe.ValidationError, jc.validate_completed_qty_matches_for_quantity) - # completed + loss + pending == for_quantity -> passes - jc.pending_qty = 4 - jc.validate_completed_qty_matches_for_quantity() - - def test_set_process_loss(self): - jc = frappe.new_doc("Job Card") - jc.for_quantity = 10 - jc.total_completed_qty = 6 - jc.pending_qty = 1 - jc.set_process_loss() - self.assertEqual(jc.process_loss_qty, 3) # 10 - 6 - 1 - - # no loss when nothing completed yet - nothing_done = frappe.new_doc("Job Card") - nothing_done.for_quantity = 10 - nothing_done.total_completed_qty = 0 - nothing_done.set_process_loss() - self.assertEqual(nothing_done.process_loss_qty, 0) - - def test_capacity_overlap_detection(self): - jc = frappe.new_doc("Job Card") - sequential = [ - {"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"}, - {"from_time": "2026-01-01 11:00:00", "to_time": "2026-01-01 12:00:00"}, - ] - overlapping = [ - {"from_time": "2026-01-01 10:00:00", "to_time": "2026-01-01 11:00:00"}, - {"from_time": "2026-01-01 10:30:00", "to_time": "2026-01-01 11:30:00"}, - ] - # sequential logs share one capacity slot; overlapping logs need two - self.assertEqual(len(jc.get_alloted_capacity(sequential)), 1) - self.assertEqual(len(jc.get_alloted_capacity(overlapping)), 2) - # capacity 1 overlaps with any log; capacity 2 only when both slots are taken - self.assertTrue(jc.has_overlap(1, sequential)) - 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 - 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) ->>>>>>> 4b3904c6d7 (test: semi FG job card is exempt from the legacy transfer qty check) diff --git a/erpnext/manufacturing/doctype/work_order/services/status.py b/erpnext/manufacturing/doctype/work_order/services/status.py deleted file mode 100644 index 74f204acd40..00000000000 --- a/erpnext/manufacturing/doctype/work_order/services/status.py +++ /dev/null @@ -1,471 +0,0 @@ -# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -"""Status and quantity-rollup logic for Work Order. - -Extracted from work_order.py. ``StatusService`` wraps a Work Order document -(composition); work_order.py keeps thin delegating stubs so the many external -callers (job cards, sales orders, production plans, patches) keep working. -""" - -import frappe -from frappe import _ -from frappe.query_builder.functions import Sum -from frappe.utils import cint, flt, get_link_to_form - -from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty - -_QTY_PURPOSES = ( - ("Manufacture", "produced_qty"), - ("Material Transfer for Manufacture", "material_transferred_for_manufacturing"), - ("Material Transfer for Manufacture", "additional_transferred_qty"), -) - - -class StatusService: - def __init__(self, doc): - self.doc = doc - - def validate_work_order_against_so(self): - from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError - - total_qty = flt(self._ordered_qty_against_so()) + flt(self.doc.qty) - so_qty = flt(self._so_item_qty()) + flt(self._packed_item_qty()) - allowance_percentage = flt( - frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_sales_order") - ) - if total_qty <= so_qty + (allowance_percentage / 100 * so_qty): - return - - frappe.throw( - _("Cannot produce more Item {0} than Sales Order quantity {1} {2}").format( - get_link_to_form("Item", self.doc.production_item), - frappe.bold(so_qty), - frappe.bold(frappe.get_value("Item", self.doc.production_item, "stock_uom")), - ), - OverProductionError, - ) - - def _ordered_qty_against_so(self): - wo = frappe.qb.DocType("Work Order") - return ( - frappe.qb.from_(wo) - .select(Sum(wo.qty - wo.process_loss_qty)) - .where( - (wo.production_item == self.doc.production_item) - & (wo.sales_order == self.doc.sales_order) - & (wo.docstatus == 1) - & (wo.status != "Closed") - & (wo.name != self.doc.name) - ) - ).run()[0][0] - - def _so_item_qty(self): - so_item = frappe.qb.DocType("Sales Order Item") - return ( - frappe.qb.from_(so_item) - .select(Sum(so_item.stock_qty)) - .where( - (so_item.parent == self.doc.sales_order) - & (so_item.item_code == self.doc.production_item) - & (so_item.docstatus == 1) - ) - ).run()[0][0] - - def _packed_item_qty(self): - packed_item = frappe.qb.DocType("Packed Item") - return ( - frappe.qb.from_(packed_item) - .select(Sum(packed_item.qty)) - .where( - (packed_item.parent == self.doc.sales_order) - & (packed_item.parenttype == "Sales Order") - & (packed_item.item_code == self.doc.production_item) - & (packed_item.docstatus == 1) - ) - ).run()[0][0] - - def update_status(self, status=None): - """Update status of work order if unknown""" - if self.doc.docstatus == 1: - # Refresh material_transferred_for_manufacturing before deciding status so pick-list- - # driven transfers (where this qty is derived from item transfers, not fg_completed_qty) - # are reflected immediately, instead of only after the next status update call. - self.doc.refresh_material_transferred_for_manufacturing() - - if self.doc.status != "Closed": - if status not in ["Stopped", "Closed"]: - status = self.get_status(status) - - if status != self.doc.status: - self.doc.db_set("status", status) - - self.doc.update_required_items() - - return status or self.doc.status - - def get_status(self, status=None): - """Return the status based on stock entries against this work order""" - status = status or self.doc.status - - if self.doc.docstatus == 0: - status = "Draft" - elif self.doc.docstatus == 1: - status = self._submitted_status(status) - else: - status = "Cancelled" - - if self._is_partial_skip_transfer(): - status = "In Process" - - if status != "Completed" and not all(d.status == "Pending" for d in self.doc.operations): - status = "In Process" - - if status == "Not Started" and self.doc.reserve_stock: - status = self._reservation_status(status) - - return status - - def _submitted_status(self, status): - if status in ["Closed", "Stopped"]: - return status - - status = ( - "In Process" - if flt(self.doc.material_transferred_for_manufacturing) > 0 - or self.doc.skip_transfer - or self._has_transferred_material() - else "Not Started" - ) - precision = frappe.get_precision("Work Order", "produced_qty") - total_qty = flt(self.doc.produced_qty, precision) + flt(self.doc.process_loss_qty, precision) - if flt(total_qty, precision) >= flt(self.doc.qty, precision): - status = "Completed" - return status - - def _has_transferred_material(self): - """True if any raw material was transferred against this work order via a pick list - or a material request (these leave material_transferred_for_manufacturing at 0 via - the min-fraction rule).""" - ste = frappe.qb.DocType("Stock Entry") - ste_child = frappe.qb.DocType("Stock Entry Detail") - mr_child = frappe.qb.DocType("Stock Entry Detail") - # Stock Entry only carries `material_request` at the child-row level, so a Stock - # Entry is "MR-sourced" if *any* of its rows link back to a Material Request; once - # that's established, sum every row's transfer_qty, not just the linked ones (a - # manually appended extra row on the same entry has no material_request of its own). - mr_sourced_stock_entries = ( - frappe.qb.from_(mr_child).select(mr_child.parent).where(mr_child.material_request.isnotnull()) - ) - qty = ( - frappe.qb.from_(ste) - .inner_join(ste_child) - .on(ste_child.parent == ste.name) - .select(Sum(ste_child.transfer_qty)) - .where( - (ste.work_order == self.doc.name) - & (ste.docstatus == 1) - & (ste.purpose == "Material Transfer for Manufacture") - & (ste.is_return == 0) - & (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries)) - ) - ).run()[0][0] - return flt(qty) > 0 - - def _is_partial_skip_transfer(self): - return bool( - self.doc.skip_transfer - and self.doc.produced_qty - and self.doc.qty > (flt(self.doc.produced_qty) + flt(self.doc.process_loss_qty)) - ) - - def _reservation_status(self, status): - for row in self.doc.required_items: - if not row.stock_reserved_qty: - continue - - if row.stock_reserved_qty >= row.required_qty: - status = "Stock Reserved" - else: - return "Stock Partially Reserved" - return status - - def update_work_order_qty(self): - """Update Manufactured Qty and Material Transferred for Qty based on Stock Entry""" - if self.doc.track_semi_finished_goods: - return - - for purpose, fieldname in _QTY_PURPOSES: - self._update_qty_for_purpose(purpose, fieldname) - - if self.doc.production_plan: - self.set_produced_qty_for_sub_assembly_item() - self.update_production_plan_status() - - if self.doc.additional_transferred_qty: - self.doc.validate_additional_transferred_qty() - - def _update_qty_for_purpose(self, purpose, fieldname): - from erpnext.manufacturing.doctype.work_order.work_order import StockOverProductionError - - if self._skip_transfer_purpose(purpose): - return - - qty = self.get_transferred_or_manufactured_qty(purpose, fieldname) - completed_qty = self.doc.qty + (self._qty_allowance(purpose) / 100 * self.doc.qty) - if qty > completed_qty: - frappe.throw( - _("{0} ({1}) cannot be greater than planned quantity ({2}) in Work Order {3}").format( - _(self.doc.meta.get_label(fieldname)), qty, completed_qty, self.doc.name - ), - StockOverProductionError, - ) - - self.doc.db_set(fieldname, qty) - self.set_process_loss_qty() - self._update_produced_qty_in_so() - - def _skip_transfer_purpose(self, purpose): - return bool( - purpose == "Material Transfer for Manufacture" - and self.doc.operations - and self.doc.transfer_material_against == "Job Card" - ) - - def _qty_allowance(self, purpose): - allowance = flt( - frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order") - ) - if not allowance and purpose == "Material Transfer for Manufacture": - allowance = flt( - frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage") - ) - return allowance - - def _update_produced_qty_in_so(self): - from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item - - if ( - self.doc.sales_order - and self.doc.sales_order_item - and not self.doc.production_plan_sub_assembly_item - ): - update_produced_qty_in_so_item(self.doc.sales_order, self.doc.sales_order_item) - - def update_disassembled_qty(self, qty, is_cancel=False): - if is_cancel: - self.doc.disassembled_qty = max(0, self.doc.disassembled_qty - qty) - else: - if self.doc.docstatus == 1: - self.doc.disassembled_qty += qty - - if not is_cancel and self.doc.disassembled_qty > self.doc.produced_qty: - frappe.throw(_("Cannot disassemble more than produced quantity.")) - - self.doc.db_set("disassembled_qty", self.doc.disassembled_qty) - - def get_transferred_or_manufactured_qty(self, purpose, fieldname): - parent = frappe.qb.DocType("Stock Entry") - is_additional = cint(fieldname == "additional_transferred_qty") - query = frappe.qb.from_(parent).where(self._stock_entry_filter(parent, purpose, is_additional)) - - if purpose == "Manufacture": - child = frappe.qb.DocType("Stock Entry Detail") - query = ( - query.join(child) - .on(parent.name == child.parent) - .select(Sum(child.transfer_qty)) - .where(child.is_finished_item == 1) - ) - else: - query = query.select(Sum(parent.fg_completed_qty)) - - return flt(query.run()[0][0]) - - def _stock_entry_filter(self, parent, purpose, is_additional): - return ( - (parent.work_order == self.doc.name) - & (parent.docstatus == 1) - & (parent.purpose == purpose) - & (parent.is_additional_transfer_entry == is_additional) - ) - - 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) - .select(Sum(table.process_loss_qty)) - .where( - (table.work_order == self.doc.name) - & (table.purpose == "Manufacture") - & (table.docstatus == 1) - ) - ).run()[0][0] - - return flt(process_loss_qty) - - def update_production_plan_status(self): - production_plan = frappe.get_doc("Production Plan", self.doc.production_plan) - produced_qty = 0 - if self.doc.production_plan_item: - total_qty = frappe.get_all( - "Work Order", - fields=[{"SUM": "produced_qty", "as": "produced_qty"}], - filters={ - "docstatus": 1, - "production_plan": self.doc.production_plan, - "production_plan_item": self.doc.production_plan_item, - }, - as_list=1, - ) - - produced_qty = total_qty[0][0] if total_qty else 0 - - self.update_status() - production_plan.run_method("update_produced_pending_qty", produced_qty, self.doc.production_plan_item) - - def update_planned_qty(self): - if self.doc.track_semi_finished_goods: - return - - update_bin_qty(self.doc.production_item, self.doc.fg_warehouse, self._planned_qty_dict()) - - if self.doc.material_request: - mr_obj = frappe.get_doc("Material Request", self.doc.material_request) - mr_obj.update_requested_qty([self.doc.material_request_item]) - - def _planned_qty_dict(self): - from erpnext.manufacturing.doctype.production_plan.production_plan import ( - get_reserved_qty_for_sub_assembly, - ) - - qty_dict = {"planned_qty": get_planned_qty(self.doc.production_item, self.doc.fg_warehouse)} - if self.doc.production_plan_sub_assembly_item and self.doc.production_plan: - qty_dict["reserved_qty_for_production_plan"] = get_reserved_qty_for_sub_assembly( - self.doc.production_item, self.doc.fg_warehouse - ) - return qty_dict - - def set_produced_qty_for_sub_assembly_item(self): - produced_qty = self._sub_assembly_produced_qty() - frappe.db.set_value( - "Production Plan Sub Assembly Item", - self.doc.production_plan_sub_assembly_item, - "wo_produced_qty", - produced_qty, - ) - - def _sub_assembly_produced_qty(self): - table = frappe.qb.DocType("Work Order") - query = ( - frappe.qb.from_(table) - .select(Sum(table.produced_qty)) - .where( - (table.production_plan == self.doc.production_plan) - & (table.production_plan_sub_assembly_item == self.doc.production_plan_sub_assembly_item) - & (table.docstatus == 1) - ) - ).run() - return flt(query[0][0]) if query else 0 - - def update_ordered_qty(self): - if not ( - self.doc.production_plan - and (self.doc.production_plan_item or self.doc.production_plan_sub_assembly_item) - ): - return - - qty = self._production_plan_ordered_qty() - if self.doc.production_plan_item: - frappe.db.set_value("Production Plan Item", self.doc.production_plan_item, "ordered_qty", qty) - elif self.doc.production_plan_sub_assembly_item: - field = self.doc.production_plan_sub_assembly_item - frappe.db.set_value("Production Plan Sub Assembly Item", field, "ordered_qty", qty) - - doc = frappe.get_doc("Production Plan", self.doc.production_plan) - doc.set_status() - doc.db_set("status", doc.status) - - def _production_plan_ordered_qty(self): - table = frappe.qb.DocType("Work Order") - query = ( - frappe.qb.from_(table) - .select(Sum(table.qty)) - .where((table.production_plan == self.doc.production_plan) & (table.docstatus == 1)) - ) - if self.doc.production_plan_item: - query = query.where(table.production_plan_item == self.doc.production_plan_item) - elif self.doc.production_plan_sub_assembly_item: - query = query.where( - table.production_plan_sub_assembly_item == self.doc.production_plan_sub_assembly_item - ) - - result = query.run() - return flt(result[0][0]) if result else 0 - - def update_work_order_qty_in_so(self): - if ( - not self.doc.sales_order and not self.doc.sales_order_item - ) or self.doc.production_plan_sub_assembly_item: - return - - total_bundle_qty = self._total_bundle_qty() - work_order_qty = self._sales_order_work_order_qty() - frappe.db.set_value( - "Sales Order Item", - self.doc.sales_order_item, - "work_order_qty", - flt(work_order_qty / total_bundle_qty, 2), - ) - - def _sales_order_work_order_qty(self): - wo = frappe.qb.DocType("Work Order") - query = ( - frappe.qb.from_(wo) - .select(Sum(wo.qty)) - .where((wo.sales_order == self.doc.sales_order) & (wo.docstatus == 1) & (wo.status != "Closed")) - ) - if self.doc.product_bundle_item: - query = query.where(wo.product_bundle_item == self.doc.product_bundle_item) - else: - query = query.where(wo.production_item == self.doc.production_item) - - qty = query.run(as_list=1) - return qty[0][0] if qty and qty[0][0] else 0 - - def update_work_order_qty_in_combined_so(self): - total_bundle_qty = self._total_bundle_qty() - prod_plan = frappe.get_doc("Production Plan", self.doc.production_plan) - item_reference = frappe.get_value( - "Production Plan Item", self.doc.production_plan_item, "sales_order_item" - ) - - for plan_reference in prod_plan.prod_plan_references: - if plan_reference.item_reference != item_reference: - continue - - qty = flt(plan_reference.qty) / total_bundle_qty if self.doc.docstatus == 1 else 0.0 - frappe.db.set_value("Sales Order Item", plan_reference.sales_order_item, "work_order_qty", qty) - - def _total_bundle_qty(self): - if not self.doc.product_bundle_item: - return 1 - - pbi = frappe.qb.DocType("Product Bundle Item") - total_bundle_qty = ( - frappe.qb.from_(pbi).select(Sum(pbi.qty)).where(pbi.parent == self.doc.product_bundle_item) - ).run()[0][0] - # product bundle is 0 (product bundle allows 0 qty for items) - return total_bundle_qty or 1 - - def update_completed_qty_in_material_request(self): - if self.doc.material_request and self.doc.material_request_item: - frappe.get_doc("Material Request", self.doc.material_request).update_completed_qty( - [self.doc.material_request_item] - ) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 3e5304180bf..18bd6ff7998 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -4839,59 +4839,6 @@ class TestWorkOrder(ERPNextTestSuite): # generated qty (3.0 for 8 units) differs from the BOM-scaled qty (7.5 for 20 units) self.assertEqual(flt(row.qty, 6), 3.0) -<<<<<<< HEAD -======= - def test_transferred_qty_not_misattributed_between_item_and_its_substitute(self): - """When one item is transferred both for itself and as a substitute for another required item, - each transfer must be credited to the right required item. - - _material_transfer_qty_by_item grouped Stock Entry Detail by item_code only and picked - Max(original_item); for item B transferred once for itself (original_item NULL) and once as a - substitute for A (original_item=A), Max picked A and credited B's whole transfer to A, leaving - B at 0. Grouping by (item_code, original_item) and accumulating into the keyed dict attributes - each transfer correctly, deterministically on MariaDB and Postgres. - """ - from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService - - source_warehouse = "Stores - _TC" - fg_item = make_item("Test WO SelfSub FG", {"is_stock_item": 1}).name - item_a = make_item("Test WO SelfSub RM A", {"is_stock_item": 1, "allow_alternative_item": 1}).name - item_b = make_item("Test WO SelfSub RM B", {"is_stock_item": 1, "allow_alternative_item": 1}).name - - # B is a registered alternative for A - if not frappe.db.exists("Item Alternative", {"item_code": item_a, "alternative_item_code": item_b}): - frappe.get_doc( - { - "doctype": "Item Alternative", - "item_code": item_a, - "alternative_item_code": item_b, - "two_way": 1, - } - ).insert() - - # stock B generously (covers B-for-A plus B-for-itself) - for item, qty in ((item_a, 50), (item_b, 100)): - test_stock_entry.make_stock_entry( - item_code=item, target=source_warehouse, qty=qty, basic_rate=100 - ) - - make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=[item_a, item_b]) - wo = make_wo_order_test_record(item=fg_item, qty=10, source_warehouse=source_warehouse) - - transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10)) - transfer.save() - # substitute B for the A line; the existing B line stays as B's own transfer - for d in transfer.items: - if d.item_code == item_a: - d.item_code = item_b - d.original_item = item_a - transfer.submit() - - qty_by_item = RequiredItemsService(wo)._material_transfer_qty_by_item(is_return=0) - # B transferred as a substitute for A -> credited to A; B transferred for itself -> credited to B. - 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 @@ -4903,9 +4850,6 @@ class TestWorkOrder(ERPNextTestSuite): wo.wip_warehouse = "_Test Warehouse - _TC" wo.validate_warehouse() -<<<<<<< HEAD ->>>>>>> f61f6523b9 (test: WIP warehouse required for work orders tracking semi finished goods) -======= # the top-level target warehouse stays optional; operations may carry their own wo.fg_warehouse = None wo.validate_warehouse() @@ -4913,7 +4857,6 @@ class TestWorkOrder(ERPNextTestSuite): wo.track_semi_finished_goods = 0 self.assertRaises(frappe.ValidationError, wo.validate_warehouse) ->>>>>>> db99657c47 (test: target warehouse stays optional for semi FG work orders) def get_reserved_entries(voucher_no, warehouse=None): doctype = frappe.qb.DocType("Stock Reservation Entry") diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 30ed33a66a4..bb3d256ffee 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -883,6 +883,12 @@ class WorkOrder(Document): return flt(query.run()[0][0]) def set_process_loss_qty(self): + self.db_set("process_loss_qty", self._process_loss_qty()) + + def _process_loss_qty(self): + if self.track_semi_finished_goods: + return flt(sum(flt(row.process_loss_qty) for row in self.operations)) + table = frappe.qb.DocType("Stock Entry") process_loss_qty = ( frappe.qb.from_(table) @@ -892,7 +898,7 @@ class WorkOrder(Document): ) ).run()[0][0] - self.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.production_plan) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 36da536c328..0c6c3bbee9b 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3197,23 +3197,8 @@ class StockEntry(StockController, SubcontractingInwardController): 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, - ) + frappe.msgprint(_("The Process Loss Qty has reset as per job cards Process Loss Qty"), alert=True) -<<<<<<< HEAD - 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 reset as per job cards Process Loss Qty"), alert=True - ) - -======= ->>>>>>> 1b335973b7 (fix: scope manufacture entry process loss to its own job card) 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" @@ -3243,7 +3228,8 @@ class StockEntry(StockController, SubcontractingInwardController): precision = frappe.get_precision("Stock Entry Detail", "qty") pending_qty = flt( - flt(job_card.get_qty_to_produce()) + flt(job_card.for_quantity) + - flt(job_card.pending_qty) - flt(job_card.manufactured_qty) - flt(job_card.get_consumed_process_loss()), precision, 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 74996b96a22..39e77f0929d 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: available_serial_batches = self.get_transferred_serial_batches() production_share = self.get_production_share() + items_to_remove = [] for item_code, _dict in item_dict.items(): _dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse _dict.to_warehouse = "" @@ -143,11 +144,15 @@ class ManufactureEntry: 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: + items_to_remove.append(item_code) continue if self.skip_material_transfer: set_previous_operation_serial_batch(self.stock_entry, _dict) + for item_code in items_to_remove: + item_dict.pop(item_code) + self.stock_entry.add_to_stock_entry_detail(item_dict) def get_production_share(self): From 87b456faa270df150eca7be9a729b844bfc4b7c9 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 21:10:08 +0530 Subject: [PATCH 30/31] fix(manufacturing): type whitelisted BOM arguments --- erpnext/manufacturing/doctype/bom/bom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 00e754ff561..a706f5daa13 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -854,7 +854,7 @@ class BOM(WebsiteGenerator): self.add_materials_from_bom(row.finished_good, row.bom_no, row.idx, qty=row.finished_good_qty) @frappe.whitelist() - def add_raw_materials(self, operation_row_id, items): + def add_raw_materials(self, operation_row_id: str, items: str | list[dict]) -> None: if isinstance(items, str): items = parse_json(items) From a8060d5e996e1698ac6bdb2e872a70b079ef67aa Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 21:22:17 +0530 Subject: [PATCH 31/31] fix(manufacturing): adapt version 16 compatibility --- erpnext/manufacturing/doctype/bom/bom.py | 2 +- erpnext/stock/doctype/stock_entry/stock_entry.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index a706f5daa13..06f7ff894d6 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -854,7 +854,7 @@ class BOM(WebsiteGenerator): self.add_materials_from_bom(row.finished_good, row.bom_no, row.idx, qty=row.finished_good_qty) @frappe.whitelist() - def add_raw_materials(self, operation_row_id: str, items: str | list[dict]) -> None: + def add_raw_materials(self, operation_row_id: str | int, items: str | list[dict]) -> None: if isinstance(items, str): items = parse_json(items) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 0c6c3bbee9b..cd442909b1c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3238,7 +3238,8 @@ class StockEntry(StockController, SubcontractingInwardController): entry_qty = flt(finished_qty + flt(self.process_loss_qty), precision) if entry_qty > pending_qty: - uom = job_card.stock_uom + item_code = job_card.finished_good or job_card.production_item + uom = frappe.get_cached_value("Item", item_code, "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."