diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 4640f5192dd..f07e9ba013e 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -3186,6 +3186,435 @@ class TestWorkOrder(FrappeTestCase): allow_overproduction("overproduction_percentage_for_work_order", 0) +<<<<<<< HEAD +======= + def test_reserved_serial_batch(self): + raw_materials = [] + for item_code, properties in { + "Test Reserved FG Item": {"is_stock_item": 1}, + "Test Reserved Serial Item": {"has_serial_no": 1, "serial_no_series": "TSNN-RSI-.####"}, + "Test Reserved Batch Item": { + "has_batch_no": 1, + "batch_number_series": "BCH-RBI-.####", + "create_new_batch": 1, + }, + "Test Reserved Serial Batch Item": { + "has_serial_no": 1, + "serial_no_series": "TSNB-RSBI-.####", + "has_batch_no": 1, + "batch_number_series": "BCH-RSBI-.####", + "create_new_batch": 1, + }, + }.items(): + make_item(item_code, properties=properties) + if item_code != "Test Reserved FG Item": + raw_materials.append(item_code) + test_stock_entry.make_stock_entry( + item_code=item_code, + target="Stores - _TC", + qty=5, + basic_rate=100, + ) + + original_auto_reserve = frappe.db.get_single_value("Stock Settings", "auto_reserve_serial_and_batch") + original_backflush = frappe.db.get_single_value( + "Manufacturing Settings", "backflush_raw_materials_based_on" + ) + frappe.db.set_single_value( + "Manufacturing Settings", + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + frappe.db.set_single_value("Stock Settings", "auto_reserve_serial_and_batch", 1) + + make_bom( + item="Test Reserved FG Item", + source_warehouse="Stores - _TC", + raw_materials=raw_materials, + ) + + wo = make_wo_order_test_record( + item="Test Reserved FG Item", + qty=5, + source_warehouse="Stores - _TC", + reserve_stock=1, + ) + + _reserved_item = get_reserved_entries(wo.name) + for key, value in _reserved_item.items(): + self.assertEqual(key[1], "Stores - _TC") + self.assertEqual(value.reserved_qty, 5) + if value.serial_nos: + self.assertEqual(len(value.serial_nos), 5) + + if value.batch_nos: + self.assertEqual(sum(value.batch_nos.values()), 5) + + # Transfer 5 qty + mt_stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 5)) + mt_stock_entry.submit() + + for row in mt_stock_entry.items: + value = _reserved_item[(row.item_code, row.s_warehouse)] + self.assertEqual(row.qty, value.reserved_qty) + if value.serial_nos: + serial_nos = get_serial_nos_from_bundle(row.serial_and_batch_bundle) + self.assertEqual(sorted(serial_nos), sorted(value.serial_nos)) + + if value.batch_nos: + self.assertTrue(row.batch_no in value.batch_nos) + + _before_reserved_item = get_reserved_entries(wo.name, mt_stock_entry.items[0].t_warehouse) + + # Manufacture 2 qty + fg_stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 2)) + fg_stock_entry.submit() + + for row in fg_stock_entry.items: + if not row.s_warehouse: + continue + + value = _before_reserved_item[(row.item_code, row.s_warehouse)] + if row.serial_no: + serial_nos = get_serial_nos_from_bundle(row.serial_and_batch_bundle) + for sn in serial_nos: + self.assertTrue(sn in value.serial_nos) + value.serial_nos.remove(sn) + + if row.batch_no: + self.assertTrue(row.batch_no in value.batch_nos) + value.batch_nos[row.batch_no] -= row.qty + if row.serial_no: + sns = get_serial_nos_from_bundle(row.serial_and_batch_bundle) + for sn in sns: + self.assertTrue(sn in value.serial_batches[row.batch_no]) + value.serial_batches[row.batch_no].remove(sn) + + # Manufacture 3 qty + fg_stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + fg_stock_entry.submit() + + for row in fg_stock_entry.items: + if not row.s_warehouse: + continue + + value = _before_reserved_item[(row.item_code, row.s_warehouse)] + + if row.serial_no: + serial_nos = get_serial_nos_from_bundle(row.serial_and_batch_bundle) + self.assertEqual(sorted(serial_nos), sorted(value.serial_nos)) + + if row.batch_no: + self.assertTrue(row.batch_no in value.batch_nos) + self.assertEqual(value.batch_nos[row.batch_no], row.qty) + if row.serial_no: + sns = get_serial_nos_from_bundle(row.serial_and_batch_bundle) + self.assertEqual(sorted(sns), sorted(value.serial_batches[row.batch_no])) + + frappe.db.set_single_value( + "Manufacturing Settings", "backflush_raw_materials_based_on", original_backflush + ) + frappe.db.set_single_value("Stock Settings", "auto_reserve_serial_and_batch", original_auto_reserve) + + def test_phantom_bom_item_not_in_additional_cost(self): + """Test that phantom BOMs are not added to additional costs, + but regular non-stock items in the FG BOM are added.""" + + from erpnext.stock.doctype.item.test_item import make_item + + # Create items: + # - FG Item (stock item) + # - Phantom sub-assembly (non-stock item to be phantom) + # - Phantom RM (stock item - component of phantom BOM) + # - Packing Material (non-stock item - directly in FG BOM) + # - Regular RM (stock item - directly in FG BOM) + + fg_item = make_item( + "Test FG Item For Phantom Non Stock", + {"is_stock_item": 1, "valuation_rate": 100}, + ).name + + phantom_item = make_item( + "Test Phantom Sub Assembly Non Stock", + {"is_stock_item": 0, "valuation_rate": 0}, + ).name + + phantom_rm = make_item( + "Test Phantom RM Item", + {"is_stock_item": 1, "valuation_rate": 200}, + ).name + + packing_material = make_item( + "Test Packing Material Non Stock", + {"is_stock_item": 0, "valuation_rate": 150}, + ).name + + regular_rm = make_item( + "Test Regular RM Stock Item", + {"is_stock_item": 1, "valuation_rate": 100}, + ).name + + # Create price list entries for non-stock items + price_list = "_Test Price List India" + for item_code, rate in [ + (phantom_item, 500), + (phantom_rm, 200), + (packing_material, 150), + ]: + if not frappe.db.get_value("Item Price", {"item_code": item_code, "price_list": price_list}): + frappe.get_doc( + { + "doctype": "Item Price", + "item_code": item_code, + "price_list_rate": rate, + "price_list": price_list, + } + ).insert(ignore_permissions=True) + + # Create Phantom BOM (for the phantom sub-assembly) + phantom_bom = frappe.get_doc( + { + "doctype": "BOM", + "item": phantom_item, + "is_default": 1, + "is_active": 1, + "is_phantom_bom": 1, # Mark as phantom BOM + "currency": "INR", + "quantity": 1, + "company": "_Test Company", + "rm_cost_as_per": "Price List", + "buying_price_list": price_list, + } + ) + phantom_bom.append( + "items", + { + "item_code": phantom_rm, + "qty": 1, + "rate": 200, + }, + ) + phantom_bom.insert() + phantom_bom.submit() + + # Create FG BOM with phantom item, packing material, and regular RM + fg_bom = frappe.get_doc( + { + "doctype": "BOM", + "item": fg_item, + "is_default": 1, + "is_active": 1, + "currency": "INR", + "quantity": 1, + "company": "_Test Company", + "rm_cost_as_per": "Price List", + "buying_price_list": price_list, + } + ) + + # Add phantom item (will be marked as is_phantom_item based on is_phantom_bom) + fg_bom.append( + "items", + { + "item_code": phantom_item, + "qty": 1, + "rate": 200, + "bom_no": phantom_bom.name, + }, + ) + + # Add packing material (non-stock, directly in FG BOM) + fg_bom.append( + "items", + { + "item_code": packing_material, + "qty": 1, + "rate": 150, + }, + ) + + # Add regular RM (stock item) + fg_bom.append( + "items", + { + "item_code": regular_rm, + "qty": 1, + "rate": 100, + }, + ) + + fg_bom.insert() + fg_bom.submit() + + # Ensure stock + test_stock_entry.make_stock_entry( + item_code=regular_rm, + target="_Test Warehouse - _TC", + qty=10, + basic_rate=100, + ) + + test_stock_entry.make_stock_entry( + item_code=phantom_rm, + target="_Test Warehouse - _TC", + qty=10, + basic_rate=200, + ) + + # Create work order + wo = make_wo_order_test_record( + production_item=fg_item, + bom_no=fg_bom.name, + qty=1, + source_warehouse="_Test Warehouse - _TC", + ) + + # Transfer materials + se_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 1)) + se_transfer.insert() + se_transfer.submit() + + # Manufacture + se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 1)) + se_manufacture.insert() + + # Verify additional costs + self.assertTrue(se_manufacture.additional_costs, "Additional costs should not be empty") + total_additional_cost = sum(row.amount for row in se_manufacture.additional_costs) + + self.assertEqual( + total_additional_cost, + 150, # only packing material; phantom RM excluded + f"Additional cost should be 150 (packing material only), got {total_additional_cost}", + ) + + self.assertEqual( + se_manufacture.total_outgoing_value, + 300, # 100 (regular RM) + 200 (phantom RM) + f"Total outgoing value should be 300, got {se_manufacture.total_outgoing_value}", + ) + + self.assertEqual( + se_manufacture.total_incoming_value, + 450, # 300 (RM total) + 150 (packing material) + f"Total incoming value should be 450, got {se_manufacture.total_incoming_value}", + ) + + # Clean up + se_manufacture.submit() + se_manufacture.cancel() + se_transfer.cancel() + wo.reload() + wo.cancel() + fg_bom.cancel() + phantom_bom.cancel() + + def test_phantom_bom_explosion(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_tree_for_phantom_bom_tests + + expected = create_tree_for_phantom_bom_tests() + + wo = make_wo_order_test_record(item="Top Level Parent") + self.assertEqual([item.item_code for item in wo.required_items], expected) + + def test_reserved_qty_for_pp_with_extra_material_transfer(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + rm_item_code = make_item( + "_Test Reserved Qty PP Item", + { + "is_stock_item": 1, + }, + ).name + + fg_item_code = make_item( + "_Test Reserved Qty PP FG Item", + { + "is_stock_item": 1, + }, + ).name + + make_stock_entry_test_record( + item_code=rm_item_code, target="_Test Warehouse - _TC", qty=10, basic_rate=100 + ) + + make_bom( + item=fg_item_code, + raw_materials=[rm_item_code], + ) + + wo_order = make_wo_order_test_record( + item=fg_item_code, + qty=1, + source_warehouse="_Test Warehouse - _TC", + skip_transfer=0, + target_warehouse="_Test Warehouse - _TC", + ) + + bin1_at_completion = get_bin(rm_item_code, "_Test Warehouse - _TC") + self.assertEqual(bin1_at_completion.reserved_qty_for_production, 1) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 1)) + s.items[0].qty += 2 # extra material transfer + s.submit() + + bin1_at_completion = get_bin(rm_item_code, "_Test Warehouse - _TC") + + self.assertEqual(bin1_at_completion.reserved_qty_for_production, 0) + + +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 + +>>>>>>> f5378b6573 (fix: Bin reserved qty for production for extra material transfer) 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 7d8b4843828..f5a5e2693b9 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1786,6 +1786,9 @@ def get_reserved_qty_for_production( qty_field = wo_item.required_qty else: qty_field = Case() + qty_field = qty_field.when( + ((wo.skip_transfer == 0) & (wo_item.transferred_qty > wo_item.required_qty)), 0.0 + ) qty_field = qty_field.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) qty_field = qty_field.else_(wo_item.required_qty - wo_item.consumed_qty)