diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index a40d0d714ed..512515dc3ac 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "creation": "2013-02-22 01:27:49", "doctype": "DocType", "document_type": "Setup", @@ -126,11 +127,13 @@ "label": "Image" }, { + "default": "1", "fetch_from": "operation.batch_size", "fetch_if_empty": 1, "fieldname": "batch_size", - "fieldtype": "Int", - "label": "Batch Size" + "fieldtype": "Float", + "label": "Batch Size", + "non_negative": 1 }, { "depends_on": "eval:doc.parenttype == \"Routing\" || !parent.routing", @@ -196,7 +199,11 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], +<<<<<<< HEAD "modified": "2026-02-17 15:33:28.495850", +======= + "modified": "2026-05-25 17:15:42.044630", +>>>>>>> 3ad67021d6 (fix(manufacturing): allow to edit batch size while creating a work order (#55058)) "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operation", diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.py b/erpnext/manufacturing/doctype/bom_operation/bom_operation.py index 66ac02891b9..6c6c0931ab1 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.py +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.py @@ -17,7 +17,12 @@ class BOMOperation(Document): base_cost_per_unit: DF.Float base_hour_rate: DF.Currency base_operating_cost: DF.Currency +<<<<<<< HEAD batch_size: DF.Int +======= + batch_size: DF.Float + bom_no: DF.Link | None +>>>>>>> 3ad67021d6 (fix(manufacturing): allow to edit batch size while creating a work order (#55058)) cost_per_unit: DF.Float description: DF.TextEditor | None fixed_time: DF.Check diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index f4a0d6f6145..dcb664d485b 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -3631,6 +3631,178 @@ class TestWorkOrder(FrappeTestCase): self.assertEqual(bin1_at_completion.reserved_qty_for_production, 0) +<<<<<<< HEAD +======= + def test_operating_time(self): + workstation = make_workstation(workstation="Test Workstation for Operating Time") + raw_material = make_item(item_code="Raw Material 1", properties={"is_stock_item": 1}) + subassembly_item = make_item(item_code="Subassembly Item", properties={"is_stock_item": 1}) + subassembly_bom = make_bom( + item=subassembly_item.name, + quantity=5, + raw_materials=[raw_material.name], + rm_qty=25, + with_operations=1, + do_not_submit=True, + ) + subassembly_operation = make_operation(operation="Subassembly Operation") + subassembly_bom.append( + "operations", + { + "operation": subassembly_operation.name, + "time_in_mins": 60, + "workstation": workstation.name, + }, + ) + subassembly_bom.save() + subassembly_bom.submit() + + fg_item = make_item(item_code="FG Item", properties={"is_stock_item": 1}) + fg_bom = make_bom( + item=fg_item.name, + quantity=50, + raw_materials=[subassembly_item.name], + rm_qty=3, + with_operations=1, + do_not_submit=True, + ) + fg_operation = make_operation(operation="FG Operation") + fg_operation.create_job_card_based_on_batch_size = 1 + fg_operation.batch_size = 25 + fg_operation.save() + fg_bom.append( + "operations", + { + "operation": fg_operation.name, + "batch_size": fg_operation.batch_size, + "time_in_mins": 60, + "workstation": workstation.name, + }, + ) + fg_bom.items[0].do_not_explode = 0 + fg_bom.items[0].bom_no = subassembly_bom.name + fg_bom.save() + fg_bom.submit() + self.assertEqual(fg_bom.operations[0].batch_size, 25) + + wo_order = make_wo_order_test_record( + item=fg_item.name, + qty=100, + use_multi_level_bom=1, + ) + self.assertEqual(wo_order.operations[0].time_in_mins, 72) + self.assertEqual(wo_order.operations[1].time_in_mins, 240) + + def test_backflush_based_on_in_bom(self): + raw_material_1 = make_item(item_code="BOM RM 1", properties={"is_stock_item": 1}).name + raw_material_2 = make_item(item_code="BOM RM 2", properties={"is_stock_item": 1}).name + fg_item = make_item(item_code="BOM FG 1", properties={"is_stock_item": 1}).name + + frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") + + backflush_based_on = frappe.db.get_single_value( + "Manufacturing Settings", "backflush_raw_materials_based_on" + ) + self.assertEqual(backflush_based_on, "BOM") + + for item_code in [raw_material_1, raw_material_2]: + test_stock_entry.make_stock_entry( + item_code=item_code, target="Stores - _TC", qty=1, basic_rate=100 + ) + + bom = make_bom( + item=fg_item, + quantity=1, + raw_materials=[raw_material_1], + backflush_based_on="Material Transferred for Manufacture", + ) + + wo_order = make_wo_order_test_record(item=fg_item, qty=1, source_warehouse="Stores - _TC") + + self.assertEqual(bom.name, wo_order.bom_no) + backflush_based_on = frappe.db.get_value("BOM", wo_order.bom_no, "backflush_based_on") + self.assertEqual(backflush_based_on, "Material Transferred for Manufacture") + + material_transfer_entry = frappe.get_doc( + make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 1) + ) + material_transfer_entry.save() + + # Add second raw material in the material transfer entry which is not in the BOM to simulate backflush based on material transfer scenario + material_transfer_entry.append( + "items", + { + "item_code": raw_material_2, + "item_name": raw_material_2, + "item_group": frappe.get_value("Item", raw_material_2, "item_group"), + "uom": frappe.get_value("Item", raw_material_2, "stock_uom"), + "conversion_factor": 1, + "s_warehouse": "Stores - _TC", + "t_warehouse": material_transfer_entry.items[0].t_warehouse, + "qty": 1, + }, + ) + + material_transfer_entry.submit() + manufacture_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 1)) + manufacture_entry.save() + + self.assertEqual(len(manufacture_entry.items), 3) + for row in manufacture_entry.items: + if row.s_warehouse: + self.assertIn(row.item_code, [raw_material_1, raw_material_2]) + + +def get_reserved_entries(voucher_no, warehouse=None): + doctype = frappe.qb.DocType("Stock Reservation Entry") + sabb = frappe.qb.DocType("Serial and Batch Entry") + + query = ( + frappe.qb.from_(doctype) + .left_join(sabb) + .on(doctype.name == sabb.parent) + .select( + doctype.name, + doctype.item_code, + doctype.warehouse, + doctype.reserved_qty, + sabb.serial_no, + sabb.batch_no, + sabb.qty, + sabb.delivered_qty, + ) + .where((doctype.voucher_no == voucher_no) & (doctype.docstatus == 1)) + ) + + if warehouse: + query = query.where(doctype.warehouse == warehouse) + + reservation_entries = query.run(as_dict=True) + + _reserved_item = frappe._dict({}) + for entry in reservation_entries: + key = (entry.item_code, entry.warehouse) + if key not in _reserved_item: + _reserved_item[key] = frappe._dict( + { + "reserved_qty": 0, + "serial_nos": [], + "batch_nos": defaultdict(int), + "serial_batches": defaultdict(list), + } + ) + + _reserved_item[key].reserved_qty += entry.qty + if entry.batch_no: + _reserved_item[key].batch_nos[entry.batch_no] += entry.qty + if entry.serial_no: + _reserved_item[key].serial_batches[entry.batch_no].append(entry.serial_no) + if entry.serial_no: + _reserved_item[key].serial_nos.append(entry.serial_no) + + return _reserved_item + +>>>>>>> 3ad67021d6 (fix(manufacturing): allow to edit batch size while creating a work order (#55058)) def make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse): from erpnext.stock.doctype.stock_entry.test_stock_entry import ( diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 0584320726f..6672c5ca6ad 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -158,7 +158,7 @@ class WorkOrder(Document): self.calculate_operating_cost() self.validate_qty() self.validate_transfer_against() - self.validate_operation_time() + self.validate_operations() self.status = self.get_status() self.validate_workstation_type() self.reset_use_multi_level_bom() @@ -1120,9 +1120,16 @@ class WorkOrder(Document): title=_("Missing value"), ) - def validate_operation_time(self): + def validate_operations(self): for d in self.operations: +<<<<<<< HEAD if not d.time_in_mins > 0: +======= + if not d.batch_size or d.batch_size <= 0: + d.batch_size = 1 + + if d.time_in_mins <= 0: +>>>>>>> 3ad67021d6 (fix(manufacturing): allow to edit batch size while creating a work order (#55058)) frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation)) def update_required_items(self): diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json index 38b325b73ab..92d11a6af94 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json @@ -185,10 +185,11 @@ "read_only": 1 }, { + "default": "1", "fieldname": "batch_size", "fieldtype": "Float", "label": "Batch Size", - "read_only": 1 + "non_negative": 1 }, { "fieldname": "sequence_id", @@ -225,7 +226,11 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], +<<<<<<< HEAD "modified": "2025-05-15 15:10:06.885440", +======= + "modified": "2026-05-25 17:15:12.038470", +>>>>>>> 3ad67021d6 (fix(manufacturing): allow to edit batch size while creating a work order (#55058)) "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Operation",