From bf78f6173c3f28e93f8a08fca4eb0bceb39bc789 Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Sat, 21 Jun 2025 01:14:26 +0530 Subject: [PATCH 1/5] fix: disassemble qty calculation & max calculation to be allowed to create it (cherry picked from commit 3e4d16062619b5934bff0d697ac59cf1beb3eead) # Conflicts: # erpnext/manufacturing/doctype/work_order/work_order.json # erpnext/stock/doctype/stock_entry/stock_entry.py --- .../doctype/work_order/work_order.js | 2 +- .../doctype/work_order/work_order.json | 32 ++++++++ .../doctype/work_order/work_order.py | 15 +++- .../stock/doctype/stock_entry/stock_entry.py | 75 +++++++++++++++++-- 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 04a87b00260..3db6d165328 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -803,7 +803,7 @@ erpnext.work_order = { get_max_transferable_qty: (frm, purpose) => { let max = 0; if (purpose === "Disassemble") { - return flt(frm.doc.produced_qty); + return flt(frm.doc.produced_qty - frm.doc.disassembled_qty); } if (frm.doc.skip_transfer) { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 8231e924cb0..06fe1977b52 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -20,6 +20,7 @@ "qty", "material_transferred_for_manufacturing", "produced_qty", + "disassembled_qty", "process_loss_qty", "project", "section_break_ndpq", @@ -585,7 +586,34 @@ }, { "fieldname": "section_break_ndpq", +<<<<<<< HEAD "fieldtype": "Section Break" +======= + "fieldtype": "Section Break", + "label": "Required Items" + }, + { + "default": "0", + "fetch_from": "bom_no.track_semi_finished_goods", + "fieldname": "track_semi_finished_goods", + "fieldtype": "Check", + "label": "Track Semi Finished Goods", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "reserve_stock", + "fieldtype": "Check", + "label": " Reserve Stock" + }, + { + "depends_on": "eval:doc.docstatus==1", + "fieldname": "disassembled_qty", + "fieldtype": "Float", + "label": "Disassembled Qty", + "no_copy": 1, + "read_only": 1 +>>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) } ], "icon": "fa fa-cogs", @@ -593,7 +621,11 @@ "image_field": "image", "is_submittable": 1, "links": [], +<<<<<<< HEAD "modified": "2024-02-11 15:47:13.454422", +======= + "modified": "2025-06-21 00:55:45.916224", +>>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) "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 796e9461bee..176e955ee72 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -88,6 +88,7 @@ class WorkOrder(Document): company: DF.Link corrective_operation_cost: DF.Currency description: DF.SmallText | None + disassembled_qty: DF.Float expected_delivery_date: DF.Date | None fg_warehouse: DF.Link from_wip_warehouse: DF.Check @@ -406,6 +407,18 @@ class WorkOrder(Document): self.set_produced_qty_for_sub_assembly_item() self.update_production_plan_status() + def update_disassembled_qty(self, qty, is_cancel=False): + if is_cancel: + self.disassembled_qty = max(0, self.disassembled_qty - qty) + else: + if self.docstatus == 1: + self.disassembled_qty += qty + + if not is_cancel and self.disassembled_qty > self.produced_qty: + frappe.throw(_("Cannot disassemble more than produced quantity.")) + + self.db_set("disassembled_qty", self.disassembled_qty) + def get_transferred_or_manufactured_qty(self, purpose): table = frappe.qb.DocType("Stock Entry") query = frappe.qb.from_(table).where( @@ -1475,7 +1488,7 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None): stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse stock_entry.set_stock_entry_type() - stock_entry.get_items() + stock_entry.get_items(qty, work_order.production_item) if purpose != "Disassemble": stock_entry.set_serial_no_batch_for_finished_good() diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 45afd1a0ad4..a75f5d30b21 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -27,6 +27,7 @@ from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals from erpnext.manufacturing.doctype.bom.bom import ( add_additional_cost, + get_bom_items_as_dict, get_op_cost_from_sub_assemblies, get_scrap_items_from_sub_assemblies, validate_bom_no, @@ -243,6 +244,11 @@ class StockEntry(StockController): def on_submit(self): self.validate_closed_subcontracting_order() self.make_bundle_using_old_serial_batch_fields() +<<<<<<< HEAD +======= + self.update_work_order() + self.update_disassembled_order() +>>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) self.update_stock_ledger() self.update_work_order() self.validate_subcontract_order() @@ -271,6 +277,7 @@ class StockEntry(StockController): self.validate_work_order_status() self.update_work_order() + self.update_disassembled_order(is_cancel=True) self.update_stock_ledger() self.ignore_linked_doctypes = ( @@ -1617,6 +1624,50 @@ class StockEntry(StockController): if not pro_doc.operations: pro_doc.set_actual_dates() +<<<<<<< HEAD +======= + def update_disassembled_order(self, is_cancel=False): + if not self.work_order: + return + if self.purpose == "Disassemble" and self.fg_completed_qty: + pro_doc = frappe.get_doc("Work Order", self.work_order) + pro_doc.run_method("update_disassembled_qty", self.fg_completed_qty, is_cancel) + + def make_stock_reserve_for_wip_and_fg(self): + if self.is_stock_reserve_for_work_order(): + pro_doc = frappe.get_doc("Work Order", self.work_order) + if ( + self.purpose == "Manufacture" + and not pro_doc.sales_order + and not pro_doc.production_plan_sub_assembly_item + ): + return + + pro_doc.set_reserved_qty_for_wip_and_fg(self) + + def cancel_stock_reserve_for_wip_and_fg(self): + if self.is_stock_reserve_for_work_order(): + pro_doc = frappe.get_doc("Work Order", self.work_order) + if ( + self.purpose == "Manufacture" + and not pro_doc.sales_order + and not pro_doc.production_plan_sub_assembly_item + ): + return + + pro_doc.cancel_reserved_qty_for_wip_and_fg(self) + + def is_stock_reserve_for_work_order(self): + if ( + self.work_order + and self.stock_entry_type in ["Material Transfer for Manufacture", "Manufacture"] + and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock") + ): + return True + + return False + +>>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) @frappe.whitelist() def get_item_details(self, args=None, for_update=False): item = frappe.qb.DocType("Item") @@ -1759,7 +1810,7 @@ class StockEntry(StockController): }, ) - def get_items_for_disassembly(self): + def get_items_for_disassembly(self, disassemble_qty, production_item): """Get items for Disassembly Order""" if not self.work_order: @@ -1767,9 +1818,9 @@ class StockEntry(StockController): items = self.get_items_from_manufacture_entry() - s_warehouse = "" - if self.work_order: - s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse") + s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse") + + items_dict = get_bom_items_as_dict(self.bom_no, self.company, disassemble_qty) for row in items: child_row = self.append("items", {}) @@ -1777,6 +1828,15 @@ class StockEntry(StockController): if value is not None: child_row.set(field, value) + # update qty and amount from BOM items + bom_items = items_dict.get(row.item_code) + if bom_items: + child_row.qty = bom_items.get("qty", child_row.qty) + child_row.amount = bom_items.get("amount", child_row.amount) + + if row.item_code == production_item: + child_row.qty = disassemble_qty + child_row.s_warehouse = (self.from_warehouse or s_warehouse) if row.is_finished_item else "" child_row.t_warehouse = self.to_warehouse if not row.is_finished_item else "" child_row.is_finished_item = 0 if row.is_finished_item else 1 @@ -1809,12 +1869,13 @@ class StockEntry(StockController): ) @frappe.whitelist() - def get_items(self): + def get_items(self, qty, production_item): self.set("items", []) self.validate_work_order() + # print(qty, 'qty\n\n') - if self.purpose == "Disassemble": - return self.get_items_for_disassembly() + if self.purpose == "Disassemble" and qty is not None: + return self.get_items_for_disassembly(qty, production_item) if not self.posting_date or not self.posting_time: frappe.throw(_("Posting date and posting time is mandatory")) From c69bb746ce44021f2cd68b084d8103b096884d9b Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Sun, 22 Jun 2025 21:33:02 +0530 Subject: [PATCH 2/5] fix: func parameters (cherry picked from commit ce6ace4b8ab5d774b62ebbe65ef06cb71ddf40a3) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index a75f5d30b21..8b323c0c33b 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1869,10 +1869,9 @@ class StockEntry(StockController): ) @frappe.whitelist() - def get_items(self, qty, production_item): + def get_items(self, qty=None, production_item=None): self.set("items", []) self.validate_work_order() - # print(qty, 'qty\n\n') if self.purpose == "Disassemble" and qty is not None: return self.get_items_for_disassembly(qty, production_item) From 61f4547860c7dd5e817082d57d2903801e1346e2 Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Thu, 26 Jun 2025 17:21:34 +0530 Subject: [PATCH 3/5] test: added test case for disassembly order (cherry picked from commit aee26c35508306ad64b7e4cebe3218edf7f42fda) --- .../doctype/work_order/test_work_order.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index cd57c7c24f8..5106ded95e8 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2368,6 +2368,105 @@ class TestWorkOrder(FrappeTestCase): stock_entry.submit() + def test_disassembly_order_with_qty_behavior(self): + # Create raw material and FG item + raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name + fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name + bom = make_bom(item=fg_item, quantity=10, raw_materials=[raw_item], rm_qty=5) + + # Create and submit a Work Order for 10 qty + wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started") + + # create material receipt stock entry for raw material + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + make_stock_entry_test_record( + item_code=raw_item, + purpose="Material Receipt", + target=wo.wip_warehouse, + qty=10, + basic_rate=100, + ) + make_stock_entry_test_record( + item_code=raw_item, + purpose="Material Receipt", + target=wo.fg_warehouse, + qty=10, + basic_rate=100, + ) + + # create material transfer for manufacture stock entry + se_for_material_tranfer_mfr = frappe.get_doc( + make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty) + ) + se_for_material_tranfer_mfr.items[0].s_warehouse = wo.wip_warehouse + se_for_material_tranfer_mfr.save() + se_for_material_tranfer_mfr.submit() + + se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty)) + se_for_manufacture.submit() + + # Simulate a disassembly stock entry + disassemble_qty = 4 + stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty)) + stock_entry.append( + "items", + { + "item_code": fg_item, + "qty": disassemble_qty, + "s_warehouse": wo.fg_warehouse, + }, + ) + + for bom_item in bom.items: + stock_entry.append( + "items", + { + "item_code": bom_item.item_code, + "qty": (bom_item.qty / bom.quantity) * disassemble_qty, + "t_warehouse": wo.source_warehouse, + }, + ) + + wo.reload() + stock_entry.save() + stock_entry.submit() + + # Assert FG item is present with correct qty + finished_good_entry = next((item for item in stock_entry.items if item.item_code == fg_item), None) + self.assertIsNotNone(finished_good_entry, "Finished good item missing from stock entry") + self.assertEqual( + finished_good_entry.qty, + disassemble_qty, + f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}", + ) + + # Assert raw materials + for item in stock_entry.items: + if item.item_code == fg_item: + continue + bom_item = next((i for i in bom.items if i.item_code == item.item_code), None) + if bom_item: + expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty + self.assertAlmostEqual( + item.qty, + expected_qty, + places=3, + msg=f"Raw item {item.item_code} qty mismatch: expected {expected_qty}, got {item.qty}", + ) + else: + self.fail(f"Unexpected item {item.item_code} found in stock entry") + + wo.reload() + # Assert disassembled_qty field updated in Work Order + self.assertEqual( + wo.disassembled_qty, + disassemble_qty, + f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}", + ) + def test_components_alternate_item_for_bom_based_manufacture_entry(self): frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1) @@ -3118,6 +3217,7 @@ def make_wo_order_test_record(**args): wo_order.transfer_material_against = args.transfer_material_against or "Work Order" wo_order.from_wip_warehouse = args.from_wip_warehouse or 0 wo_order.batch_size = args.batch_size or 0 + wo_order.status = args.status or "Draft" if args.source_warehouse: wo_order.source_warehouse = args.source_warehouse From abfe3c83656029a422adf85845d5694f2e366eaa Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 30 Jun 2025 12:45:04 +0530 Subject: [PATCH 4/5] chore: fix conflicts --- .../doctype/work_order/work_order.json | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 06fe1977b52..f1735ab64b5 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -586,25 +586,7 @@ }, { "fieldname": "section_break_ndpq", -<<<<<<< HEAD "fieldtype": "Section Break" -======= - "fieldtype": "Section Break", - "label": "Required Items" - }, - { - "default": "0", - "fetch_from": "bom_no.track_semi_finished_goods", - "fieldname": "track_semi_finished_goods", - "fieldtype": "Check", - "label": "Track Semi Finished Goods", - "read_only": 1 - }, - { - "default": "0", - "fieldname": "reserve_stock", - "fieldtype": "Check", - "label": " Reserve Stock" }, { "depends_on": "eval:doc.docstatus==1", @@ -613,7 +595,6 @@ "label": "Disassembled Qty", "no_copy": 1, "read_only": 1 ->>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) } ], "icon": "fa fa-cogs", @@ -621,11 +602,7 @@ "image_field": "image", "is_submittable": 1, "links": [], -<<<<<<< HEAD - "modified": "2024-02-11 15:47:13.454422", -======= "modified": "2025-06-21 00:55:45.916224", ->>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", @@ -661,4 +638,4 @@ "title_field": "production_item", "track_changes": 1, "track_seen": 1 -} \ No newline at end of file +} From c404faaa6d05eab9f5bfabecebcc0358a3fc6c2e Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 30 Jun 2025 12:46:52 +0530 Subject: [PATCH 5/5] chore: fix issue --- .../stock/doctype/stock_entry/stock_entry.py | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 8b323c0c33b..974acc3f701 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -244,11 +244,7 @@ class StockEntry(StockController): def on_submit(self): self.validate_closed_subcontracting_order() self.make_bundle_using_old_serial_batch_fields() -<<<<<<< HEAD -======= - self.update_work_order() self.update_disassembled_order() ->>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) self.update_stock_ledger() self.update_work_order() self.validate_subcontract_order() @@ -1624,8 +1620,6 @@ class StockEntry(StockController): if not pro_doc.operations: pro_doc.set_actual_dates() -<<<<<<< HEAD -======= def update_disassembled_order(self, is_cancel=False): if not self.work_order: return @@ -1633,41 +1627,6 @@ class StockEntry(StockController): pro_doc = frappe.get_doc("Work Order", self.work_order) pro_doc.run_method("update_disassembled_qty", self.fg_completed_qty, is_cancel) - def make_stock_reserve_for_wip_and_fg(self): - if self.is_stock_reserve_for_work_order(): - pro_doc = frappe.get_doc("Work Order", self.work_order) - if ( - self.purpose == "Manufacture" - and not pro_doc.sales_order - and not pro_doc.production_plan_sub_assembly_item - ): - return - - pro_doc.set_reserved_qty_for_wip_and_fg(self) - - def cancel_stock_reserve_for_wip_and_fg(self): - if self.is_stock_reserve_for_work_order(): - pro_doc = frappe.get_doc("Work Order", self.work_order) - if ( - self.purpose == "Manufacture" - and not pro_doc.sales_order - and not pro_doc.production_plan_sub_assembly_item - ): - return - - pro_doc.cancel_reserved_qty_for_wip_and_fg(self) - - def is_stock_reserve_for_work_order(self): - if ( - self.work_order - and self.stock_entry_type in ["Material Transfer for Manufacture", "Manufacture"] - and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock") - ): - return True - - return False - ->>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) @frappe.whitelist() def get_item_details(self, args=None, for_update=False): item = frappe.qb.DocType("Item")