diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index d1f2cdbd1aa..627e5262dc9 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2053,6 +2053,55 @@ class TestWorkOrder(FrappeTestCase): "BOM", ) + def test_disassemby_order(self): + fg_item = "Test Disassembly Item" + source_warehouse = "Stores - _TC" + raw_materials = ["Test Disassembly RM Item 1", "Test Disassembly RM Item 2"] + + make_item(fg_item, {"is_stock_item": 1}) + for item in raw_materials: + make_item(item, {"is_stock_item": 1}) + test_stock_entry.make_stock_entry( + item_code=item, + target=source_warehouse, + qty=1, + basic_rate=100, + ) + + make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=raw_materials) + + wo = make_wo_order_test_record( + item=fg_item, + qty=1, + source_warehouse=source_warehouse, + skip_transfer=1, + ) + + stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 1)) + for row in stock_entry.items: + if row.item_code in raw_materials: + row.s_warehouse = source_warehouse + + stock_entry.submit() + + wo.reload() + self.assertEqual(wo.status, "Completed") + + stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", 1)) + stock_entry.save() + + self.assertEqual(stock_entry.purpose, "Disassemble") + + for row in stock_entry.items: + if row.item_code == fg_item: + self.assertTrue(row.s_warehouse) + self.assertFalse(row.t_warehouse) + else: + self.assertFalse(row.s_warehouse) + self.assertTrue(row.t_warehouse) + + stock_entry.submit() + def make_operation(**kwargs): kwargs = frappe._dict(kwargs) @@ -2370,6 +2419,7 @@ def make_wo_order_test_record(**args): wo_order.batch_size = args.batch_size or 0 if args.source_warehouse: + wo_order.source_warehouse = args.source_warehouse for item in wo_order.get("required_items"): item.source_warehouse = args.source_warehouse diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 1da33f0ad9b..ae888c79714 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -183,13 +183,30 @@ frappe.ui.form.on("Work Order", { } } + if (frm.doc.status == "Completed") { + if (frm.doc.__onload.backflush_raw_materials_based_on == "Material Transferred for Manufacture") { + frm.add_custom_button( + __("BOM"), + () => { + frm.trigger("make_bom"); + }, + __("Create") + ); + } + } + if ( - frm.doc.status == "Completed" && - frm.doc.__onload.backflush_raw_materials_based_on == "Material Transferred for Manufacture" + frm.doc.docstatus === 1 && + ["Closed", "Completed"].includes(frm.doc.status) && + frm.doc.produced_qty > 0 ) { - frm.add_custom_button(__("Create BOM"), () => { - frm.trigger("make_bom"); - }); + frm.add_custom_button( + __("Disassembly Order"), + () => { + frm.trigger("make_disassembly_order"); + }, + __("Create") + ); } frm.trigger("add_custom_button_to_return_components"); @@ -337,6 +354,23 @@ frappe.ui.form.on("Work Order", { }); }, + make_disassembly_order(frm) { + erpnext.work_order + .show_prompt_for_qty_input(frm, "Disassemble") + .then((data) => { + return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", { + work_order_id: frm.doc.name, + purpose: "Disassemble", + qty: data.qty, + target_warehouse: data.target_warehouse, + }); + }) + .then((stock_entry) => { + frappe.model.sync(stock_entry); + frappe.set_route("Form", stock_entry.doctype, stock_entry.name); + }); + }, + show_progress_for_items: function (frm) { var bars = []; var message = ""; @@ -745,6 +779,10 @@ erpnext.work_order = { get_max_transferable_qty: (frm, purpose) => { let max = 0; + if (purpose === "Disassemble") { + return flt(frm.doc.produced_qty); + } + if (frm.doc.skip_transfer) { max = flt(frm.doc.qty) - flt(frm.doc.produced_qty); } else { @@ -759,15 +797,38 @@ erpnext.work_order = { show_prompt_for_qty_input: function (frm, purpose) { let max = this.get_max_transferable_qty(frm, purpose); + + let fields = [ + { + fieldtype: "Float", + label: __("Qty for {0}", [__(purpose)]), + fieldname: "qty", + description: __("Max: {0}", [max]), + default: max, + }, + ]; + + if (purpose === "Disassemble") { + fields.push({ + fieldtype: "Link", + options: "Warehouse", + fieldname: "target_warehouse", + label: __("Target Warehouse"), + default: frm.doc.source_warehouse || frm.doc.wip_warehouse, + get_query() { + return { + filters: { + company: frm.doc.company, + is_group: 0, + }, + }; + }, + }); + } + return new Promise((resolve, reject) => { frappe.prompt( - { - fieldtype: "Float", - label: __("Qty for {0}", [__(purpose)]), - fieldname: "qty", - description: __("Max: {0}", [max]), - default: max, - }, + fields, (data) => { max += (frm.doc.qty * (frm.doc.__onload.overproduction_percentage || 0.0)) / 100; diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 904e42a6a03..057c49949ec 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1359,7 +1359,7 @@ def set_work_order_ops(name): @frappe.whitelist() -def make_stock_entry(work_order_id, purpose, qty=None): +def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None): work_order = frappe.get_doc("Work Order", work_order_id) if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"): wip_warehouse = work_order.wip_warehouse @@ -1389,9 +1389,16 @@ def make_stock_entry(work_order_id, purpose, qty=None): stock_entry.to_warehouse = work_order.fg_warehouse stock_entry.project = work_order.project + if purpose == "Disassemble": + stock_entry.from_warehouse = work_order.fg_warehouse + stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse + stock_entry.set_stock_entry_type() stock_entry.get_items() - stock_entry.set_serial_no_batch_for_finished_good() + + if purpose != "Disassemble": + stock_entry.set_serial_no_batch_for_finished_good() + return stock_entry.as_dict() diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 31ba6b23c01..feaac948099 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -372,4 +372,5 @@ erpnext.patches.v15_0.update_asset_repair_field_in_stock_entry erpnext.patches.v15_0.update_total_number_of_booked_depreciations erpnext.patches.v15_0.do_not_use_batchwise_valuation erpnext.patches.v15_0.drop_index_posting_datetime_from_sle +erpnext.patches.v15_0.add_disassembly_order_stock_entry_type #1 erpnext.patches.v15_0.set_standard_stock_entry_type diff --git a/erpnext/patches/v15_0/add_disassembly_order_stock_entry_type.py b/erpnext/patches/v15_0/add_disassembly_order_stock_entry_type.py new file mode 100644 index 00000000000..1f3413b172b --- /dev/null +++ b/erpnext/patches/v15_0/add_disassembly_order_stock_entry_type.py @@ -0,0 +1,13 @@ +import frappe + + +def execute(): + if not frappe.db.exists("Stock Entry Type", "Disassemble"): + frappe.get_doc( + { + "doctype": "Stock Entry Type", + "name": "Disassemble", + "purpose": "Disassemble", + "is_standard": 1, + } + ).insert(ignore_permissions=True) diff --git a/erpnext/patches/v15_0/set_standard_stock_entry_type.py b/erpnext/patches/v15_0/set_standard_stock_entry_type.py index 7551b0d4afc..4a721abae40 100644 --- a/erpnext/patches/v15_0/set_standard_stock_entry_type.py +++ b/erpnext/patches/v15_0/set_standard_stock_entry_type.py @@ -11,6 +11,7 @@ def execute(): "Manufacture", "Repack", "Send to Subcontractor", + "Disassemble", ]: if frappe.db.exists("Stock Entry Type", stock_entry_type): frappe.db.set_value("Stock Entry Type", stock_entry_type, "is_standard", 1) diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index a99289416ba..270a9e06054 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -96,6 +96,7 @@ def install(country=None): "purpose": "Repack", "is_standard": 1, }, + {"doctype": "Stock Entry Type", "name": "Disassemble", "purpose": "Disassemble", "is_standard": 1}, { "doctype": "Stock Entry Type", "name": "Send to Subcontractor", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 3f467d3627a..5ba9f2973a1 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -127,7 +127,7 @@ "label": "Purpose", "oldfieldname": "purpose", "oldfieldtype": "Select", - "options": "Material Issue\nMaterial Receipt\nMaterial Transfer\nMaterial Transfer for Manufacture\nMaterial Consumption for Manufacture\nManufacture\nRepack\nSend to Subcontractor", + "options": "Material Issue\nMaterial Receipt\nMaterial Transfer\nMaterial Transfer for Manufacture\nMaterial Consumption for Manufacture\nManufacture\nRepack\nSend to Subcontractor\nDisassemble", "read_only": 1, "search_index": 1 }, @@ -143,7 +143,7 @@ "reqd": 1 }, { - "depends_on": "eval:in_list([\"Material Transfer for Manufacture\", \"Manufacture\", \"Material Consumption for Manufacture\"], doc.purpose)", + "depends_on": "eval:in_list([\"Material Transfer for Manufacture\", \"Manufacture\", \"Material Consumption for Manufacture\", \"Disassemble\"], doc.purpose)", "fieldname": "work_order", "fieldtype": "Link", "label": "Work Order", @@ -242,7 +242,7 @@ }, { "default": "0", - "depends_on": "eval:in_list([\"Material Issue\", \"Material Transfer\", \"Manufacture\", \"Repack\", \"Send to Subcontractor\", \"Material Transfer for Manufacture\", \"Material Consumption for Manufacture\"], doc.purpose)", + "depends_on": "eval:in_list([\"Material Issue\", \"Material Transfer\", \"Manufacture\", \"Repack\", \"Send to Subcontractor\", \"Material Transfer for Manufacture\", \"Material Consumption for Manufacture\", \"Disassemble\"], doc.purpose)", "fieldname": "from_bom", "fieldtype": "Check", "label": "From BOM", @@ -697,7 +697,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-08-13 19:02:42.386955", + "modified": "2024-08-13 19:05:42.386955", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 6b4164dee5f..0ef54c78555 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -132,6 +132,7 @@ class StockEntry(StockController): "Manufacture", "Repack", "Send to Subcontractor", + "Disassemble", ] remarks: DF.Text | None sales_invoice_no: DF.Link | None @@ -337,6 +338,7 @@ class StockEntry(StockController): "Repack", "Send to Subcontractor", "Material Consumption for Manufacture", + "Disassemble", ] if self.purpose not in valid_purposes: @@ -616,6 +618,7 @@ class StockEntry(StockController): "Manufacture", "Material Transfer for Manufacture", "Material Consumption for Manufacture", + "Disassemble", ): # check if work order is entered @@ -1703,11 +1706,63 @@ class StockEntry(StockController): }, ) + def get_items_for_disassembly(self): + """Get items for Disassembly Order""" + + if not self.work_order: + frappe.throw(_("The Work Order is mandatory for Disassembly Order")) + + 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") + + for row in items: + child_row = self.append("items", {}) + for field, value in row.items(): + if value is not None: + child_row.set(field, value) + + 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 + + def get_items_from_manufacture_entry(self): + return frappe.get_all( + "Stock Entry", + fields=[ + "`tabStock Entry Detail`.`item_code`", + "`tabStock Entry Detail`.`item_name`", + "`tabStock Entry Detail`.`description`", + "`tabStock Entry Detail`.`qty`", + "`tabStock Entry Detail`.`transfer_qty`", + "`tabStock Entry Detail`.`stock_uom`", + "`tabStock Entry Detail`.`uom`", + "`tabStock Entry Detail`.`basic_rate`", + "`tabStock Entry Detail`.`conversion_factor`", + "`tabStock Entry Detail`.`is_finished_item`", + "`tabStock Entry Detail`.`batch_no`", + "`tabStock Entry Detail`.`serial_no`", + "`tabStock Entry Detail`.`use_serial_batch_fields`", + ], + filters=[ + ["Stock Entry", "purpose", "=", "Manufacture"], + ["Stock Entry", "work_order", "=", self.work_order], + ["Stock Entry", "docstatus", "=", 1], + ["Stock Entry Detail", "docstatus", "=", 1], + ], + order_by="`tabStock Entry Detail`.`idx` desc, `tabStock Entry Detail`.`is_finished_item` desc", + ) + @frappe.whitelist() def get_items(self): self.set("items", []) self.validate_work_order() + if self.purpose == "Disassemble": + return self.get_items_for_disassembly() + if not self.posting_date or not self.posting_time: frappe.throw(_("Posting date and posting time is mandatory")) diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.json b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.json index 9b426a6c940..c522df59941 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.json +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.json @@ -17,7 +17,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Purpose", - "options": "\nMaterial Issue\nMaterial Receipt\nMaterial Transfer\nMaterial Transfer for Manufacture\nMaterial Consumption for Manufacture\nManufacture\nRepack\nSend to Subcontractor", + "options": "\nMaterial Issue\nMaterial Receipt\nMaterial Transfer\nMaterial Transfer for Manufacture\nMaterial Consumption for Manufacture\nManufacture\nRepack\nSend to Subcontractor\nDisassemble", "reqd": 1, "set_only_once": 1 }, @@ -37,10 +37,11 @@ } ], "links": [], - "modified": "2024-08-20 15:35:45.696958", + "modified": "2024-08-24 16:00:22.696958", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Type", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { 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 efbdd680c69..8d05a43408b 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py @@ -27,6 +27,7 @@ class StockEntryType(Document): "Manufacture", "Repack", "Send to Subcontractor", + "Disassemble", ] # end: auto-generated types @@ -45,5 +46,6 @@ class StockEntryType(Document): "Manufacture", "Repack", "Send to Subcontractor", + "Disassemble", ]: frappe.throw(f"Stock Entry Type {self.name} cannot be set as standard")