# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt


from collections import defaultdict

import frappe
from frappe.tests import timeout
from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today

from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError
from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry as make_stock_entry_from_jc
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.work_order import (
	CapacityError,
	ItemHasVariantError,
	OverProductionError,
	StockOverProductionError,
	close_work_order,
	make_job_card,
	make_material_request,
	make_stock_entry,
	make_stock_return_entry,
	stop_unstop,
)
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
	get_batch_from_bundle,
	get_serial_nos_from_bundle,
	make_serial_batch_bundle,
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.utils import get_bin
from erpnext.tests.utils import ERPNextTestSuite


class TestWorkOrder(ERPNextTestSuite):
	def setUp(self):
		self.warehouse = "_Test Warehouse 2 - _TC"
		self.item = "_Test Item"
		prepare_data_for_backflush_based_on_materials_transferred()

	def check_planned_qty(self):
		planned0 = (
			frappe.db.get_value(
				"Bin", {"item_code": "_Test FG Item", "warehouse": "_Test Warehouse 1 - _TC"}, "planned_qty"
			)
			or 0
		)

		wo_order = make_wo_order_test_record()

		planned1 = frappe.db.get_value(
			"Bin", {"item_code": "_Test FG Item", "warehouse": "_Test Warehouse 1 - _TC"}, "planned_qty"
		)

		self.assertEqual(planned1, planned0 + 10)

		# add raw materials to stores
		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="Stores - _TC", qty=100, basic_rate=100
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=100, basic_rate=100
		)

		# from stores to wip
		s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 4))
		for d in s.get("items"):
			d.s_warehouse = "Stores - _TC"
		s.insert()
		s.submit()

		# from wip to fg
		s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 4))
		s.insert()
		s.submit()

		self.assertEqual(frappe.db.get_value("Work Order", wo_order.name, "produced_qty"), 4)

		planned2 = frappe.db.get_value(
			"Bin", {"item_code": "_Test FG Item", "warehouse": "_Test Warehouse 1 - _TC"}, "planned_qty"
		)

		self.assertEqual(planned2, planned0 + 6)

		return wo_order

	def test_over_production(self):
		wo_doc = self.check_planned_qty()

		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="_Test Warehouse - _TC", qty=100, basic_rate=100
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=100, basic_rate=100
		)

		s = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 7))
		s.insert()

		self.assertRaises(StockOverProductionError, s.submit)

	def test_planned_operating_cost(self):
		wo_order = make_wo_order_test_record(
			item="_Test FG Item 2", planned_start_date=now(), qty=1, do_not_save=True
		)
		wo_order.set_work_order_operations()
		cost = wo_order.planned_operating_cost
		wo_order.qty = 2
		wo_order.set_work_order_operations()
		self.assertEqual(wo_order.planned_operating_cost, cost * 2)

	def test_reserved_qty_for_partial_completion(self):
		item = "_Test Item"
		warehouse = "_Test Warehouse - _TC"

		bin1_at_start = get_bin(item, warehouse)

		# reset to correct value
		bin1_at_start.update_reserved_qty_for_production()

		wo_order = make_wo_order_test_record(
			item="_Test FG Item", qty=2, source_warehouse=warehouse, skip_transfer=1
		)

		reserved_qty_on_submission = cint(get_bin(item, warehouse).reserved_qty_for_production)

		# reserved qty for production is updated
		self.assertEqual(cint(bin1_at_start.reserved_qty_for_production) + 2, reserved_qty_on_submission)

		test_stock_entry.make_stock_entry(item_code="_Test Item", target=warehouse, qty=100, basic_rate=100)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100", target=warehouse, qty=100, basic_rate=100
		)

		s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 1))
		s.submit()

		bin1_at_completion = get_bin(item, warehouse)

		self.assertEqual(cint(bin1_at_completion.reserved_qty_for_production), reserved_qty_on_submission - 1)

	def test_production_item(self):
		wo_order = make_wo_order_test_record(item="_Test FG Item", qty=1, do_not_save=True)
		frappe.db.set_value("Item", "_Test FG Item", "end_of_life", "2000-1-1")

		self.assertRaises(frappe.ValidationError, wo_order.save)

		frappe.db.set_value("Item", "_Test FG Item", "end_of_life", None)
		frappe.db.set_value("Item", "_Test FG Item", "disabled", 1)

		self.assertRaises(frappe.ValidationError, wo_order.save)

		frappe.db.set_value("Item", "_Test FG Item", "disabled", 0)

		wo_order = make_wo_order_test_record(item="_Test Variant Item", qty=1, do_not_save=True)
		self.assertRaises(ItemHasVariantError, wo_order.save)

	def test_reserved_qty_for_production_submit(self):
		self.bin1_at_start = get_bin(self.item, self.warehouse)

		# reset to correct value
		self.bin1_at_start.update_reserved_qty_for_production()

		self.wo_order = make_wo_order_test_record(
			item="_Test FG Item", qty=2, source_warehouse=self.warehouse
		)

		self.bin1_on_submit = get_bin(self.item, self.warehouse)

		# reserved qty for production is updated
		self.assertEqual(
			cint(self.bin1_at_start.reserved_qty_for_production) + 2,
			cint(self.bin1_on_submit.reserved_qty_for_production),
		)
		self.assertEqual(cint(self.bin1_at_start.projected_qty), cint(self.bin1_on_submit.projected_qty) + 2)

	def test_reserved_qty_for_production_cancel(self):
		self.test_reserved_qty_for_production_submit()

		self.wo_order.cancel()

		bin1_on_cancel = get_bin(self.item, self.warehouse)

		# reserved_qty_for_producion updated
		self.assertEqual(
			cint(self.bin1_at_start.reserved_qty_for_production),
			cint(bin1_on_cancel.reserved_qty_for_production),
		)
		self.assertEqual(self.bin1_at_start.projected_qty, cint(bin1_on_cancel.projected_qty))

	def test_reserved_qty_for_production_on_stock_entry(self):
		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target=self.warehouse, qty=100, basic_rate=100
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100", target=self.warehouse, qty=100, basic_rate=100
		)

		self.test_reserved_qty_for_production_submit()

		s = frappe.get_doc(make_stock_entry(self.wo_order.name, "Material Transfer for Manufacture", 2))

		s.submit()

		bin1_on_start_production = get_bin(self.item, self.warehouse)

		# reserved_qty_for_producion updated
		self.assertEqual(
			cint(self.bin1_at_start.reserved_qty_for_production),
			cint(bin1_on_start_production.reserved_qty_for_production),
		)

		# projected qty will now be 2 less (becuase of item movement)
		self.assertEqual(
			cint(self.bin1_at_start.projected_qty), cint(bin1_on_start_production.projected_qty) + 2
		)

		s = frappe.get_doc(make_stock_entry(self.wo_order.name, "Manufacture", 2))

		bin1_on_end_production = get_bin(self.item, self.warehouse)

		# no change in reserved / projected
		self.assertEqual(
			cint(bin1_on_end_production.reserved_qty_for_production),
			cint(bin1_on_start_production.reserved_qty_for_production),
		)

	def test_reserved_qty_for_production_closed(self):
		wo1 = make_wo_order_test_record(item="_Test FG Item", qty=2, source_warehouse=self.warehouse)
		item = wo1.required_items[0].item_code
		bin_before = get_bin(item, self.warehouse)
		bin_before.update_reserved_qty_for_production()

		make_wo_order_test_record(item="_Test FG Item", qty=2, source_warehouse=self.warehouse)
		close_work_order(wo1.name, "Closed")

		bin_after = get_bin(item, self.warehouse)
		self.assertEqual(bin_before.reserved_qty_for_production, bin_after.reserved_qty_for_production)

	def test_backflush_qty_for_overpduction_manufacture(self):
		cancel_stock_entry = []
		allow_overproduction("overproduction_percentage_for_work_order", 30)
		wo_order = make_wo_order_test_record(planned_start_date=now(), qty=100)
		ste1 = test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="_Test Warehouse - _TC", qty=120, basic_rate=5000.0
		)
		ste2 = test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100",
			target="_Test Warehouse - _TC",
			qty=240,
			basic_rate=1000.0,
		)

		cancel_stock_entry.extend([ste1.name, ste2.name])

		s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 60))
		s.submit()
		cancel_stock_entry.append(s.name)

		s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 60))
		s.submit()
		cancel_stock_entry.append(s.name)

		s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 60))
		s.submit()
		cancel_stock_entry.append(s.name)

		s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 50))
		s1.submit()
		cancel_stock_entry.append(s1.name)

		self.assertEqual(s1.items[0].qty, 50)
		self.assertEqual(s1.items[1].qty, 100)
		cancel_stock_entry.reverse()
		for ste in cancel_stock_entry:
			doc = frappe.get_doc("Stock Entry", ste)
			doc.cancel()

		allow_overproduction("overproduction_percentage_for_work_order", 0)

	def test_reserved_qty_for_stopped_production(self):
		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target=self.warehouse, qty=100, basic_rate=100
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100", target=self.warehouse, qty=100, basic_rate=100
		)

		# 	0 0 0

		self.test_reserved_qty_for_production_submit()

		# 2 0 -2

		s = frappe.get_doc(make_stock_entry(self.wo_order.name, "Material Transfer for Manufacture", 1))

		s.submit()

		# 1 -1 0

		bin1_on_start_production = get_bin(self.item, self.warehouse)

		# reserved_qty_for_producion updated
		self.assertEqual(
			cint(self.bin1_at_start.reserved_qty_for_production) + 1,
			cint(bin1_on_start_production.reserved_qty_for_production),
		)

		# projected qty will now be 2 less (becuase of item movement)
		self.assertEqual(
			cint(self.bin1_at_start.projected_qty), cint(bin1_on_start_production.projected_qty) + 2
		)

		# STOP
		stop_unstop(self.wo_order.name, "Stopped")

		bin1_on_stop_production = get_bin(self.item, self.warehouse)

		# no change in reserved / projected
		self.assertEqual(
			cint(bin1_on_stop_production.reserved_qty_for_production),
			cint(self.bin1_at_start.reserved_qty_for_production),
		)
		self.assertEqual(
			cint(bin1_on_stop_production.projected_qty) + 1, cint(self.bin1_at_start.projected_qty)
		)

	def test_secondary_material_qty(self):
		wo_order = make_wo_order_test_record(planned_start_date=now(), qty=2)

		# add raw materials to stores
		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0
		)

		s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 2))
		for d in s.get("items"):
			d.s_warehouse = "Stores - _TC"
		s.insert()
		s.submit()

		s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 2))
		s.insert()
		s.submit()

		wo_order_details = frappe.db.get_value(
			"Work Order", wo_order.name, ["scrap_warehouse", "qty", "produced_qty", "bom_no"], as_dict=1
		)

		secondary_item_details = get_secondary_item_details(wo_order_details.bom_no)

		self.assertEqual(wo_order_details.produced_qty, 2)

		for item in s.items:
			if item.bom_no and item.item_code in secondary_item_details:
				self.assertEqual(wo_order_details.scrap_warehouse, item.t_warehouse)
				self.assertEqual(
					flt(wo_order_details.qty) * flt(secondary_item_details[item.item_code]), item.qty
				)

	def test_allow_overproduction(self):
		allow_overproduction("overproduction_percentage_for_work_order", 0)
		wo_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100",
			target="_Test Warehouse - _TC",
			qty=10,
			basic_rate=1000.0,
		)

		s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 3))
		s.insert()
		self.assertRaises(StockOverProductionError, s.submit)

		allow_overproduction("overproduction_percentage_for_work_order", 50)
		s.load_from_db()
		s.submit()
		self.assertEqual(s.docstatus, 1)

		allow_overproduction("overproduction_percentage_for_work_order", 0)

	def test_over_production_for_sales_order(self):
		so = make_sales_order(item_code="_Test FG Item", qty=2)

		allow_overproduction("overproduction_percentage_for_sales_order", 0)
		wo_order = make_wo_order_test_record(
			planned_start_date=now(), sales_order=so.name, qty=3, do_not_save=True
		)

		self.assertRaises(OverProductionError, wo_order.save)

		allow_overproduction("overproduction_percentage_for_sales_order", 50)
		wo_order = make_wo_order_test_record(planned_start_date=now(), sales_order=so.name, qty=3)

		self.assertEqual(wo_order.docstatus, 1)

		allow_overproduction("overproduction_percentage_for_sales_order", 0)

	def test_work_order_with_non_stock_item(self):
		items = {
			"Finished Good Test Item For non stock": 1,
			"_Test FG Item": 1,
			"_Test FG Non Stock Item": 0,
		}
		for item, is_stock_item in items.items():
			make_item(item, {"is_stock_item": is_stock_item})

		if not frappe.db.get_value("Item Price", {"item_code": "_Test FG Non Stock Item"}):
			frappe.get_doc(
				{
					"doctype": "Item Price",
					"item_code": "_Test FG Non Stock Item",
					"price_list_rate": 1000,
					"price_list": "_Test Price List India",
				}
			).insert(ignore_permissions=True)

		fg_item = "Finished Good Test Item For non stock"
		test_stock_entry.make_stock_entry(
			item_code="_Test FG Item", target="_Test Warehouse - _TC", qty=1, basic_rate=100
		)

		if not frappe.db.get_value("BOM", {"item": fg_item, "docstatus": 1}):
			bom = make_bom(
				item=fg_item,
				rate=1000,
				raw_materials=["_Test FG Item", "_Test FG Non Stock Item"],
				do_not_save=True,
			)
			bom.rm_cost_as_per = "Price List"  # non stock item won't have valuation rate
			bom.buying_price_list = "_Test Price List India"
			bom.currency = "INR"
			bom.save()

		wo = make_wo_order_test_record(production_item=fg_item)

		se = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 1))
		se.insert()
		se.submit()

		ste = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 1))
		ste.insert()
		self.assertEqual(len(ste.additional_costs), 1)
		self.assertEqual(ste.total_additional_costs, 1000)

	@timeout(seconds=60)
	def test_job_card(self):
		stock_entries = []
		bom = frappe.get_doc(
			"BOM", {"docstatus": 1, "with_operations": 1, "company": "_Test Company", "has_variants": 0}
		)
		work_order = make_wo_order_test_record(
			item=bom.item, qty=1, bom_no=bom.name, source_warehouse="_Test Warehouse - _TC"
		)

		for row in work_order.required_items:
			stock_entry_doc = test_stock_entry.make_stock_entry(
				item_code=row.item_code, target="_Test Warehouse - _TC", qty=row.required_qty, basic_rate=100
			)
			stock_entries.append(stock_entry_doc)

		ste = frappe.get_doc(make_stock_entry(work_order.name, "Material Transfer for Manufacture", 1))
		ste.submit()
		stock_entries.append(ste)

		job_cards = frappe.get_all(
			"Job Card", filters={"work_order": work_order.name}, order_by="creation asc"
		)
		self.assertEqual(len(job_cards), len(bom.operations))

		for _i, job_card in enumerate(job_cards):
			doc = frappe.get_doc("Job Card", job_card)
			for row in doc.scheduled_time_logs:
				doc.append(
					"time_logs",
					{
						"from_time": row.from_time,
						"to_time": row.to_time,
						"time_in_mins": row.time_in_mins,
						"completed_qty": 0,
					},
				)

			doc.time_logs[0].completed_qty = 1
			doc.submit()

		ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
		ste1.submit()
		stock_entries.append(ste1)

		for job_card in job_cards:
			doc = frappe.get_doc("Job Card", job_card)
			self.assertRaises(JobCardCancelError, doc.cancel)

		stock_entries.reverse()
		for stock_entry in stock_entries:
			stock_entry.cancel()

	def test_work_order_material_transferred_qty_with_process_loss(self):
		stock_entries = []
		item_code = make_item("_Test Item For Process Loss", {"is_stock_item": 1}).name
		rm_item_code = make_item("Test Item For Process Loss RM", {"is_stock_item": 1}).name

		bom = make_bom(
			item=item_code,
			raw_materials=[rm_item_code],
			with_operations=1,
			do_not_save=True,
		)

		operation_name = "_Test Custom Operation"
		workstation_name = "_Test Custom Workstation"

		if not frappe.db.exists("Workstation", workstation_name):
			doc = frappe.new_doc("Workstation")
			doc.workstation_name = workstation_name
			doc.save()

		if not frappe.db.exists("Operation", operation_name):
			doc = frappe.new_doc("Operation")
			doc.name = operation_name
			doc.workstation = workstation_name
			doc.save()

		operation = {
			"operation": operation_name,
			"workstation": workstation_name,
			"description": "Test Data",
			"operating_cost": 100,
			"time_in_mins": 40,
		}

		bom.append("operations", operation)
		bom.save()
		bom.submit()

		work_order = make_wo_order_test_record(
			item=bom.item,
			qty=2,
			bom_no=bom.name,
			source_warehouse="_Test Warehouse - _TC",
			transfer_material_against="Job Card",
		)

		self.assertEqual(work_order.qty, 2)

		for row in work_order.required_items:
			stock_entry_doc = test_stock_entry.make_stock_entry(
				item_code=row.item_code, target="_Test Warehouse - _TC", qty=row.required_qty, basic_rate=100
			)
			stock_entries.append(stock_entry_doc)

		job_cards = frappe.get_all(
			"Job Card", filters={"work_order": work_order.name}, order_by="creation asc"
		)

		for row in job_cards:
			transfer_entry_1 = make_stock_entry_from_jc(row.name)
			transfer_entry_1.submit()

			doc = frappe.get_doc("Job Card", row.name)
			for row in doc.scheduled_time_logs:
				doc.append(
					"time_logs",
					{
						"from_time": row.from_time,
						"to_time": row.to_time,
						"time_in_mins": row.time_in_mins,
						"completed_qty": 1,
					},
				)

			doc.save()
			doc.submit()

			self.assertEqual(doc.total_completed_qty, 1)
			self.assertEqual(doc.process_loss_qty, 1)

		work_order.reload()

		self.assertEqual(work_order.material_transferred_for_manufacturing, 2)

		for row in work_order.operations:
			self.assertEqual(row.completed_qty, 1)
			self.assertEqual(row.process_loss_qty, 1)

	def test_capcity_planning(self):
		frappe.db.set_single_value(
			"Manufacturing Settings", {"disable_capacity_planning": 0, "capacity_planning_for_days": 1}
		)

		data = frappe.get_cached_value(
			"BOM",
			{"docstatus": 1, "item": "_Test FG Item 2", "with_operations": 1, "company": "_Test Company"},
			["name", "item"],
		)

		if data:
			bom, bom_item = data

			planned_start_date = add_months(today(), months=-1)
			work_order = make_wo_order_test_record(
				item=bom_item, qty=10, bom_no=bom, planned_start_date=planned_start_date
			)

			work_order1 = make_wo_order_test_record(
				item=bom_item, qty=30, bom_no=bom, planned_start_date=planned_start_date, do_not_submit=1
			)

			self.assertRaises(CapacityError, work_order1.submit)

			frappe.db.set_single_value("Manufacturing Settings", {"capacity_planning_for_days": 30})

			work_order1.reload()
			work_order1.submit()
			self.assertTrue(work_order1.docstatus, 1)

			work_order1.cancel()
			work_order.cancel()

	def test_planned_qty_updates_after_closing_work_order(self):
		item_code = "_Test FG Item"
		fg_warehouse = "_Test Warehouse 1 - _TC"

		planned_before = (
			frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": fg_warehouse}, "planned_qty")
			or 0
		)

		wo = make_wo_order_test_record(item=item_code, fg_warehouse=fg_warehouse, qty=10)

		planned_after_submit = (
			frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": fg_warehouse}, "planned_qty")
			or 0
		)
		self.assertEqual(planned_after_submit, planned_before + 10)

		close_work_order(wo.name, "Closed")

		self.assertEqual(frappe.db.get_value("Work Order", wo.name, "status"), "Closed")

		planned_after_close = (
			frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": fg_warehouse}, "planned_qty")
			or 0
		)
		self.assertEqual(planned_after_close, planned_before)

	def test_work_order_with_non_transfer_item(self):
		frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")

		items = {"Finished Good Transfer Item": 1, "_Test FG Item": 1, "_Test FG Item 1": 0}
		for item, allow_transfer in items.items():
			make_item(item, {"include_item_in_manufacturing": allow_transfer})

		fg_item = "Finished Good Transfer Item"
		test_stock_entry.make_stock_entry(
			item_code="_Test FG Item", target="_Test Warehouse - _TC", qty=1, basic_rate=100
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test FG Item 1", target="_Test Warehouse - _TC", qty=1, basic_rate=100
		)

		if not frappe.db.get_value("BOM", {"item": fg_item}):
			make_bom(item=fg_item, raw_materials=["_Test FG Item", "_Test FG Item 1"])

		wo = make_wo_order_test_record(production_item=fg_item)
		ste = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 1))
		ste.insert()
		ste.submit()
		self.assertEqual(len(ste.items), 1)
		ste1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 1))
		self.assertEqual(len(ste1.items), 3)

	def test_cost_center_for_manufacture(self):
		wo_order = make_wo_order_test_record()
		ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty)
		self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC")

	@ERPNextTestSuite.change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0})
	def test_cost_center_for_manufacture_falls_back_to_item_group_default(self):
		# "_Test Item Group" is master data with buying_cost_center already set to
		# "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its
		# BOM need to be created, since no existing item in that group has one.
		fg_item = make_item(
			"_Test FG Item For Item Group Cost Center",
			{"is_stock_item": 1, "item_group": "_Test Item Group", "include_item_in_manufacturing": 1},
		)

		if not frappe.db.exists("BOM", {"item": fg_item.name, "is_active": 1, "is_default": 1}):
			make_bom(item=fg_item.name, raw_materials=["_Test Item"])

		wo_order = make_wo_order_test_record(
			production_item=fg_item.name, skip_transfer=1, source_warehouse="_Test Warehouse - _TC"
		)
		ste = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", wo_order.qty))
		ste.insert()

		fg_row = next(d for d in ste.items if d.is_finished_item)
		self.assertEqual(fg_row.cost_center, "_Test Cost Center 2 - _TC")

	def test_operation_time_with_batch_size(self):
		fg_item = "Test Batch Size Item For BOM"
		rm1 = "Test Batch Size Item RM 1 For BOM"

		for item in ["Test Batch Size Item For BOM", "Test Batch Size Item RM 1 For BOM"]:
			make_item(item, {"include_item_in_manufacturing": 1, "is_stock_item": 1})

		bom_name = frappe.db.get_value("BOM", {"item": fg_item, "is_active": 1, "with_operations": 1}, "name")

		if not bom_name:
			bom = make_bom(item=fg_item, rate=1000, raw_materials=[rm1], do_not_save=True)
			bom.with_operations = 1
			operation = make_operation(operation="Batch Size Operation")
			operation.create_job_card_based_on_batch_size = 1
			operation.save()

			bom.append(
				"operations",
				{
					"operation": "Batch Size Operation",
					"workstation": "_Test Workstation 1",
					"description": "Test Data",
					"operating_cost": 100,
					"time_in_mins": 40,
					"batch_size": 5,
				},
			)

			bom.save()
			bom.submit()
			bom_name = bom.name

		work_order = make_wo_order_test_record(
			item=fg_item, planned_start_date=now(), qty=1, do_not_save=True
		)

		work_order.set_work_order_operations()
		work_order.save()
		self.assertEqual(work_order.operations[0].time_in_mins, 8.0)

		work_order1 = make_wo_order_test_record(
			item=fg_item, planned_start_date=now(), qty=5, do_not_save=True
		)

		work_order1.set_work_order_operations()
		work_order1.save()
		self.assertEqual(work_order1.operations[0].time_in_mins, 40.0)

	@ERPNextTestSuite.change_settings("Global Defaults", {"default_company": "_Test Company"})
	def test_batch_size_for_fg_item(self):
		fg_item = "Test Batch Size Item For BOM 3"
		rm1 = "Test Batch Size Item RM 1 For BOM 3"

		frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 0)
		for item in ["Test Batch Size Item For BOM 3", "Test Batch Size Item RM 1 For BOM 3"]:
			item_args = {"include_item_in_manufacturing": 1, "is_stock_item": 1}

			if item == fg_item:
				item_args["has_batch_no"] = 1
				item_args["create_new_batch"] = 1
				item_args["batch_number_series"] = "TBSI3.#####"

			make_item(item, item_args)

		bom_name = frappe.db.get_value("BOM", {"item": fg_item, "is_active": 1, "with_operations": 1}, "name")

		if not bom_name:
			bom = make_bom(item=fg_item, rate=1000, raw_materials=[rm1], do_not_save=True)
			bom.save()
			bom.submit()
			bom_name = bom.name

		ste1 = test_stock_entry.make_stock_entry(
			item_code=rm1, target="_Test Warehouse - _TC", qty=32, basic_rate=5000.0
		)

		work_order = make_wo_order_test_record(
			item=fg_item, skip_transfer=True, planned_start_date=now(), qty=1
		)
		ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
		for row in ste1.get("items"):
			if row.is_finished_item:
				self.assertEqual(row.item_code, fg_item)

		work_order = make_wo_order_test_record(
			item=fg_item, skip_transfer=True, planned_start_date=now(), qty=1
		)
		frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 1)
		ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
		for row in ste1.get("items"):
			if row.is_finished_item:
				self.assertEqual(row.item_code, fg_item)

		work_order = make_wo_order_test_record(
			item=fg_item,
			skip_transfer=True,
			planned_start_date=now(),
			qty=30,
			do_not_save=True,
			source_warehouse="_Test Warehouse - _TC",
		)
		work_order.batch_size = 10
		work_order.insert()
		work_order.submit()
		self.assertEqual(work_order.has_batch_no, 1)
		batches = frappe.get_all("Batch", filters={"reference_name": work_order.name})
		self.assertEqual(len(batches), 3)
		batches = [batch.name for batch in batches]

		ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 10))
		for row in ste1.get("items"):
			if row.is_finished_item:
				self.assertEqual(row.item_code, fg_item)
				self.assertEqual(row.qty, 10)

				bundle_id = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
				for bundle_row in bundle_id.get("entries"):
					self.assertTrue(bundle_row.batch_no in batches)
					batches.remove(bundle_row.batch_no)

		ste1.submit()

		remaining_batches = []
		ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 20))
		for row in ste1.get("items"):
			if row.is_finished_item:
				self.assertEqual(row.item_code, fg_item)
				self.assertEqual(row.qty, 20)

				bundle_id = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
				for bundle_row in bundle_id.get("entries"):
					self.assertTrue(bundle_row.batch_no in batches)
					remaining_batches.append(bundle_row.batch_no)

		self.assertEqual(sorted(remaining_batches), sorted(batches))

		frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 0)

	def test_partial_material_consumption(self):
		frappe.db.set_single_value("Manufacturing Settings", "material_consumption", 1)
		wo_order = make_wo_order_test_record(planned_start_date=now(), qty=4)

		ste_cancel_list = []
		ste1 = test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=5000.0
		)
		ste2 = test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100",
			target="_Test Warehouse - _TC",
			qty=20,
			basic_rate=1000.0,
		)

		ste_cancel_list.extend([ste1, ste2])

		s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 4))
		s.submit()
		ste_cancel_list.append(s)

		ste1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 2))
		ste1.submit()
		ste_cancel_list.append(ste1)

		ste3 = frappe.get_doc(make_stock_entry(wo_order.name, "Material Consumption for Manufacture", 2))
		self.assertEqual(ste3.fg_completed_qty, 2)

		expected_qty = {"_Test Item": 2, "_Test Item Home Desktop 100": 4}
		for row in ste3.items:
			self.assertEqual(row.qty, expected_qty.get(row.item_code))
		ste_cancel_list.reverse()
		for ste_doc in ste_cancel_list:
			ste_doc.cancel()

		frappe.db.set_single_value("Manufacturing Settings", "material_consumption", 0)

	def test_extra_material_transfer(self):
		frappe.db.set_single_value("Manufacturing Settings", "material_consumption", 0)
		frappe.db.set_single_value(
			"Manufacturing Settings",
			"backflush_raw_materials_based_on",
			"Material Transferred for Manufacture",
		)

		wo_order = make_wo_order_test_record(planned_start_date=now(), qty=4)

		ste_cancel_list = []
		ste1 = test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=5000.0
		)
		ste2 = test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100",
			target="_Test Warehouse - _TC",
			qty=20,
			basic_rate=1000.0,
		)

		ste_cancel_list.extend([ste1, ste2])

		itemwise_qty = {}
		s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 4))
		for row in s.items:
			row.qty = row.qty + 2
			itemwise_qty.setdefault(row.item_code, row.qty)

		s.submit()
		ste_cancel_list.append(s)

		ste3 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 2))
		for ste_row in ste3.items:
			if itemwise_qty.get(ste_row.item_code) and ste_row.s_warehouse:
				self.assertEqual(ste_row.qty, itemwise_qty.get(ste_row.item_code) / 2)

		ste3.submit()
		ste_cancel_list.append(ste3)

		ste2 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 2))
		for ste_row in ste2.items:
			if itemwise_qty.get(ste_row.item_code) and ste_row.s_warehouse:
				self.assertEqual(ste_row.qty, itemwise_qty.get(ste_row.item_code) / 2)
		ste_cancel_list.reverse()
		for ste_doc in ste_cancel_list:
			ste_doc.cancel()

		frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")

	def test_make_stock_entry_for_customer_provided_item(self):
		finished_item = "Test Item for Make Stock Entry 1"
		make_item(finished_item, {"include_item_in_manufacturing": 1, "is_stock_item": 1})

		customer_provided_item = "CUST-0987"
		make_item(
			customer_provided_item,
			{
				"is_purchase_item": 0,
				"is_customer_provided_item": 1,
				"is_stock_item": 1,
				"include_item_in_manufacturing": 1,
				"customer": "_Test Customer",
			},
		)

		if not frappe.db.exists("BOM", {"item": finished_item}):
			make_bom(item=finished_item, raw_materials=[customer_provided_item], rm_qty=1)

		company = "_Test Company with perpetual inventory"
		customer_warehouse = create_warehouse("Test Customer Provided Warehouse", company=company)
		wo = make_wo_order_test_record(
			item=finished_item, qty=1, source_warehouse=customer_warehouse, company=company
		)

		ste = frappe.get_doc(make_stock_entry(wo.name, purpose="Material Transfer for Manufacture"))
		ste.insert()

		self.assertEqual(len(ste.items), 1)
		for item in ste.items:
			self.assertEqual(item.allow_zero_valuation_rate, 1)
			self.assertEqual(item.valuation_rate, 0)

	def test_valuation_rate_missing_on_make_stock_entry(self):
		item_name = "Test Valuation Rate Missing"
		rm_item = "_Test raw material item"
		make_item(
			item_name,
			{
				"is_stock_item": 1,
				"include_item_in_manufacturing": 1,
			},
		)
		make_item(
			"_Test raw material item",
			{
				"is_stock_item": 1,
				"include_item_in_manufacturing": 1,
			},
		)

		if not frappe.db.get_value("BOM", {"item": item_name}):
			make_bom(item=item_name, raw_materials=[rm_item], rm_qty=1)

		company = "_Test Company with perpetual inventory"
		source_warehouse = create_warehouse("Test Valuation Rate Missing Warehouse", company=company)
		wo = make_wo_order_test_record(
			item=item_name, qty=1, source_warehouse=source_warehouse, company=company
		)

		stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture"))
		self.assertRaises(frappe.ValidationError, stock_entry.save)

	def test_wo_completion_with_pl_bom(self):
		from erpnext.manufacturing.doctype.bom.test_bom import (
			create_bom_with_process_loss_item,
			create_process_loss_bom_items,
		)

		qty = 10
		source_warehouse = "Stores - _TC"
		wip_warehouse = "_Test Warehouse - _TC"
		fg_item_non_whole, _, bom_item = create_process_loss_bom_items()

		test_stock_entry.make_stock_entry(
			item_code=bom_item.item_code, target=source_warehouse, qty=qty, basic_rate=100
		)

		bom_no = f"BOM-{fg_item_non_whole.item_code}-001"
		if not frappe.db.exists("BOM", bom_no):
			bom_doc = create_bom_with_process_loss_item(
				fg_item_non_whole,
				bom_item,
				fg_qty=1,
				process_loss_percentage=10,
				company="_Test Company",
			)
			bom_doc.submit()

		wo = make_wo_order_test_record(
			production_item=fg_item_non_whole.item_code,
			bom_no=bom_no,
			wip_warehouse=wip_warehouse,
			qty=qty,
			skip_transfer=1,
			source_warehouse=wip_warehouse,
			stock_uom=fg_item_non_whole.stock_uom,
		)

		se = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", qty))
		se.get("items")[0].s_warehouse = "Stores - _TC"
		se.get("items")[0].t_warehouse = wip_warehouse
		se.insert()
		se.submit()

		se = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", qty))
		se.insert()
		se.submit()

		# Testing stock entry values
		items = se.get("items")
		self.assertEqual(len(items), 2, "There should be 3 items including process loss.")
		fg_item = items[1]

		self.assertEqual(fg_item.qty, qty - 1)
		self.assertEqual(se.process_loss_percentage, 10)
		self.assertEqual(se.process_loss_qty, 1)

		wo.load_from_db()
		self.assertEqual(wo.status, "Completed")

	@timeout(seconds=60)
	def test_job_card_secondary_item(self):
		items = [
			"Test FG Item for Scrap Item Test",
			"Test RM Item 1 for Scrap Item Test",
			"Test RM Item 2 for Scrap Item Test",
		]

		job_cards = frappe.get_all(
			"Job Card Time Log",
			fields=["parent as name", "docstatus"],
			order_by="creation asc",
			distinct=True,
		)

		for job_card in job_cards:
			if job_card.docstatus == 1:
				frappe.get_doc("Job Card", job_card.name).cancel()

			frappe.delete_doc("Job Card Time Log", job_card.name)

		company = "_Test Company with perpetual inventory"
		for item_code in items:
			create_item(
				item_code=item_code,
				is_stock_item=1,
				is_purchase_item=1,
				opening_stock=100,
				valuation_rate=10,
				company=company,
				warehouse="Stores - TCP1",
			)

		item = "Test FG Item for Scrap Item Test"
		raw_materials = ["Test RM Item 1 for Scrap Item Test", "Test RM Item 2 for Scrap Item Test"]
		if not frappe.db.get_value("BOM", {"item": item}):
			bom = make_bom(
				item=item, source_warehouse="Stores - TCP1", raw_materials=raw_materials, do_not_save=True
			)
			bom.with_operations = 1
			bom.append(
				"operations",
				{
					"operation": "_Test Operation 1",
					"workstation": "_Test Workstation 1",
					"hour_rate": 20,
					"time_in_mins": 60,
				},
			)

			bom.submit()

		wo_order = make_wo_order_test_record(
			item=item, company=company, planned_start_date=now(), qty=20, skip_transfer=1
		)
		job_card = frappe.db.get_value("Job Card", {"work_order": wo_order.name}, "name")
		update_job_card(job_card)

		stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
		for row in stock_entry.items:
			if row.type or row.is_legacy_scrap_item:
				self.assertEqual(row.qty, 1)

		# Partial Job Card 1 with qty 10
		wo_order = make_wo_order_test_record(
			item=item, company=company, planned_start_date=add_days(now(), 60), qty=20, skip_transfer=1
		)
		job_card = frappe.db.get_value("Job Card", {"work_order": wo_order.name}, "name")
		update_job_card(job_card, 10, 1)

		stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
		for row in stock_entry.items:
			if row.type or row.is_legacy_scrap_item:
				self.assertEqual(row.qty, 2)

		# Partial Job Card 2 with qty 10
		operations = []
		wo_order.load_from_db()
		for row in wo_order.operations:
			n_dict = row.as_dict()
			n_dict["qty"] = 10
			n_dict["pending_qty"] = 10
			operations.append(n_dict)

		make_job_card(wo_order.name, operations)
		job_card = frappe.db.get_value("Job Card", {"work_order": wo_order.name, "docstatus": 0}, "name")

	def test_close_work_order(self):
		items = [
			"Test FG Item for Closed WO",
			"Test RM Item 1 for Closed WO",
			"Test RM Item 2 for Closed WO",
		]

		company = "_Test Company with perpetual inventory"
		for item_code in items:
			create_item(
				item_code=item_code,
				is_stock_item=1,
				is_purchase_item=1,
				opening_stock=100,
				valuation_rate=10,
				company=company,
				warehouse="Stores - TCP1",
			)

		item = "Test FG Item for Closed WO"
		raw_materials = ["Test RM Item 1 for Closed WO", "Test RM Item 2 for Closed WO"]
		if not frappe.db.get_value("BOM", {"item": item}):
			bom = make_bom(
				item=item, source_warehouse="Stores - TCP1", raw_materials=raw_materials, do_not_save=True
			)
			bom.with_operations = 1
			bom.append(
				"operations",
				{
					"operation": "_Test Operation 1",
					"workstation": "_Test Workstation 1",
					"hour_rate": 20,
					"time_in_mins": 60,
				},
			)

			bom.submit()

		wo_order = make_wo_order_test_record(
			item=item, company=company, planned_start_date=now(), qty=20, skip_transfer=1
		)
		job_cards = frappe.db.get_value("Job Card", {"work_order": wo_order.name}, "name")

		if len(job_cards) == len(bom.operations):
			for jc in job_cards:
				job_card_doc = frappe.get_doc("Job Card", jc)
				job_card_doc.append(
					"time_logs",
					{"from_time": now(), "time_in_mins": 60, "completed_qty": job_card_doc.for_quantity},
				)

				job_card_doc.submit()

			close_work_order(wo_order, "Closed")
			self.assertEqual(wo_order.get("status"), "Closed")

	def test_fix_time_operations(self):
		bom = frappe.get_doc(
			{
				"doctype": "BOM",
				"item": "_Test FG Item 2",
				"is_active": 1,
				"is_default": 1,
				"quantity": 1.0,
				"company": "_Test Company",
				"with_operations": 1,
				"operations": [
					{
						"operation": "_Test Operation 1",
						"description": "_Test",
						"workstation": "_Test Workstation 1",
						"time_in_mins": 60,
						"operating_cost": 140,
						"fixed_time": 1,
					}
				],
				"items": [
					{
						"amount": 5000.0,
						"doctype": "BOM Item",
						"item_code": "_Test Item",
						"parentfield": "items",
						"qty": 1.0,
						"rate": 5000.0,
					},
				],
			}
		)
		bom.save()
		bom.submit()

		wo1 = make_wo_order_test_record(
			item=bom.item, bom_no=bom.name, qty=1, skip_transfer=1, do_not_submit=1
		)
		wo2 = make_wo_order_test_record(
			item=bom.item, bom_no=bom.name, qty=2, skip_transfer=1, do_not_submit=1
		)

		self.assertEqual(wo1.operations[0].time_in_mins, wo2.operations[0].time_in_mins)

	def test_partial_manufacture_entries(self):
		cancel_stock_entry = []

		frappe.db.set_single_value(
			"Manufacturing Settings",
			"backflush_raw_materials_based_on",
			"Material Transferred for Manufacture",
		)

		wo_order = make_wo_order_test_record(planned_start_date=now(), qty=100)
		ste1 = test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="_Test Warehouse - _TC", qty=120, basic_rate=5000.0
		)
		ste2 = test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100",
			target="_Test Warehouse - _TC",
			qty=240,
			basic_rate=1000.0,
		)

		cancel_stock_entry.extend([ste1.name, ste2.name])

		sm = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 100))
		for row in sm.get("items"):
			if row.get("item_code") == "_Test Item":
				row.qty = 120

		sm.submit()
		cancel_stock_entry.append(sm.name)

		s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 90))
		for row in s.get("items"):
			if row.get("item_code") == "_Test Item":
				self.assertEqual(row.get("qty"), 108)
		s.submit()
		cancel_stock_entry.append(s.name)

		s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5))
		for row in s1.get("items"):
			if row.get("item_code") == "_Test Item":
				self.assertEqual(row.get("qty"), 6)
		s1.submit()
		cancel_stock_entry.append(s1.name)

		s2 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5))
		for row in s2.get("items"):
			if row.get("item_code") == "_Test Item":
				self.assertEqual(row.get("qty"), 6)

		cancel_stock_entry.reverse()
		for ste in cancel_stock_entry:
			doc = frappe.get_doc("Stock Entry", ste)
			doc.cancel()

		frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")

	@ERPNextTestSuite.change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 1})
	def test_auto_batch_creation(self):
		from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom

		fg_item = frappe.generate_hash(length=20)
		child_item = frappe.generate_hash(length=20)

		bom_tree = {fg_item: {child_item: {}}}

		create_nested_bom(bom_tree, prefix="")

		item = frappe.get_doc("Item", fg_item)
		item.has_batch_no = 1
		item.create_new_batch = 0
		item.save()

		try:
			make_wo_order_test_record(item=fg_item)
		except frappe.MandatoryError:
			self.fail("Batch generation causing failing in Work Order")

	@ERPNextTestSuite.change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 1})
	@ERPNextTestSuite.change_settings("Global Defaults", {"default_company": "_Test Company"})
	def test_auto_serial_no_creation(self):
		from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom

		fg_item = frappe.generate_hash(length=20)
		child_item = frappe.generate_hash(length=20)

		bom_tree = {fg_item: {child_item: {}}}

		create_nested_bom(bom_tree, prefix="")

		item = frappe.get_doc("Item", fg_item)
		item.has_serial_no = 1
		item.serial_no_series = f"{item.name}.#####"
		item.save()

		try:
			wo_order = make_wo_order_test_record(item=fg_item, qty=2, skip_transfer=True)
			serial_nos = self.get_serial_nos_for_fg(wo_order.name)

			stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
			stock_entry.set_work_order_details()
			stock_entry.set_serial_no_batch_for_finished_good()
			for row in stock_entry.items:
				if row.item_code == fg_item:
					self.assertTrue(row.serial_and_batch_bundle)
					self.assertEqual(
						sorted(get_serial_nos_from_bundle(row.serial_and_batch_bundle)), sorted(serial_nos)
					)

		except frappe.MandatoryError:
			self.fail("Batch generation causing failing in Work Order")

	@ERPNextTestSuite.change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 1})
	@ERPNextTestSuite.change_settings("Global Defaults", {"default_company": "_Test Company"})
	def test_auto_serial_no_batch_creation(self):
		from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom

		fg_item = frappe.generate_hash(length=20)
		child_item = frappe.generate_hash(length=20)

		bom_tree = {fg_item: {child_item: {}}}

		create_nested_bom(bom_tree, prefix="")

		item = frappe.get_doc("Item", fg_item)
		item.update(
			{
				"has_serial_no": 1,
				"has_batch_no": 1,
				"serial_no_series": f"SN-TEST-{item.name}.#####",
				"create_new_batch": 1,
				"batch_number_series": f"BATCH-TEST-{item.name}.#####",
			}
		)
		item.save()

		try:
			wo_order = make_wo_order_test_record(item=fg_item, batch_size=5, qty=10, skip_transfer=True)
			serial_nos = self.get_serial_nos_for_fg(wo_order.name)

			stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
			stock_entry.set_work_order_details()
			stock_entry.set_serial_no_batch_for_finished_good()
			for row in stock_entry.items:
				if row.item_code == fg_item:
					self.assertTrue(row.serial_and_batch_bundle)
					self.assertEqual(
						sorted(get_serial_nos_from_bundle(row.serial_and_batch_bundle)), sorted(serial_nos)
					)

					sn_doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
					for row in sn_doc.entries:
						self.assertTrue(row.serial_no)
						self.assertTrue(row.batch_no)

		except frappe.MandatoryError:
			self.fail("Batch generation causing failing in Work Order")

	def get_serial_nos_for_fg(self, work_order):
		serial_nos = []
		for row in frappe.get_all("Serial No", filters={"work_order": work_order}):
			serial_nos.append(row.name)

		return serial_nos

	@ERPNextTestSuite.change_settings(
		"Manufacturing Settings",
		{"backflush_raw_materials_based_on": "Material Transferred for Manufacture"},
	)
	def test_manufacture_entry_mapped_idx_with_exploded_bom(self):
		"""Test if WO containing BOM with partial exploded items and scrap items, maps idx correctly."""
		test_stock_entry.make_stock_entry(
			item_code="_Test Item",
			target="_Test Warehouse - _TC",
			basic_rate=5000.0,
			qty=2,
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100",
			target="_Test Warehouse - _TC",
			basic_rate=1000.0,
			qty=2,
		)

		wo_order = make_wo_order_test_record(
			qty=1,
			use_multi_level_bom=1,
			skip_transfer=1,
		)

		ste_manu = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 1))

		for index, row in enumerate(ste_manu.get("items"), start=1):
			self.assertEqual(index, row.idx)

	@ERPNextTestSuite.change_settings(
		"Manufacturing Settings",
		{"backflush_raw_materials_based_on": "Material Transferred for Manufacture"},
	)
	def test_work_order_multiple_material_transfer(self):
		"""
		Test transferring multiple RMs in separate Stock Entries.
		"""
		work_order = make_wo_order_test_record(planned_start_date=now(), qty=1)
		test_stock_entry.make_stock_entry(  # stock up RM
			item_code="_Test Item",
			target="_Test Warehouse - _TC",
			qty=1,
			basic_rate=5000.0,
		)
		test_stock_entry.make_stock_entry(  # stock up RM
			item_code="_Test Item Home Desktop 100",
			target="_Test Warehouse - _TC",
			qty=2,
			basic_rate=1000.0,
		)

		transfer_entry = frappe.get_doc(
			make_stock_entry(work_order.name, "Material Transfer for Manufacture", 1)
		)
		del transfer_entry.get("items")[0]  # transfer only one RM
		transfer_entry.submit()

		# One required item is still missing, so no finished-good quantity is covered yet.
		work_order.reload()
		self.assertEqual(transfer_entry.fg_completed_qty, 0)
		self.assertEqual(work_order.material_transferred_for_manufacturing, 0)
		self.assertEqual(work_order.status, "In Process")
		self.assertEqual(work_order.required_items[0].transferred_qty, 0)
		self.assertEqual(work_order.required_items[1].transferred_qty, 2)

		final_transfer_entry = frappe.get_doc(  # transfer last RM with For Quantity = 0
			make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
		)
		final_transfer_entry.save()

		self.assertEqual(final_transfer_entry.fg_completed_qty, 0.0)
		self.assertEqual(final_transfer_entry.items[0].qty, 1)

		final_transfer_entry.submit()
		work_order.reload()

		# WO's "Material Transferred for Mfg" shows all is transferred, no RM is pending
		self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
		self.assertEqual(work_order.required_items[0].transferred_qty, 1)
		self.assertEqual(work_order.required_items[1].transferred_qty, 2)

	def test_material_transfer_claim_follows_actual_coverage(self):
		work_order = make_wo_order_test_record(planned_start_date=now(), qty=4)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100",
			target="_Test Warehouse - _TC",
			qty=20,
			basic_rate=1000.0,
		)

		transfer_entry = frappe.get_doc(
			make_stock_entry(work_order.name, "Material Transfer for Manufacture", 4)
		)
		for row in transfer_entry.items:
			if row.item_code == "_Test Item":
				row.qty = 1
		transfer_entry.submit()

		work_order.reload()
		self.assertEqual(transfer_entry.fg_completed_qty, 1)
		self.assertEqual(work_order.material_transferred_for_manufacturing, 1)

		remainder_entry = frappe.get_doc(
			make_stock_entry(work_order.name, "Material Transfer for Manufacture", 3)
		)
		remainder_entry.submit()

		work_order.reload()
		self.assertEqual(remainder_entry.fg_completed_qty, 3)
		self.assertEqual(work_order.material_transferred_for_manufacturing, 4)

	def test_material_coverage_cap_skips_manufacture_entry(self):
		work_order = make_wo_order_test_record(planned_start_date=now(), qty=1)
		manufacture_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
		manufacture_entry.pro_doc = work_order
		manufacture_entry._action = "submit"

		self.assertFalse(manufacture_entry._should_cap_completed_qty())

	def test_material_transferred_min_fraction_on_partial_pick_list(self):
		"""Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing'
		must reflect the least-transferred required item (the bottleneck), instead of being
		marked fully transferred prematurely when only some materials are transferred.
		"""
		work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0
		)

		required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}

		# pick-list transfer: For Quantity = 0
		transfer_entry = frappe.get_doc(
			make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
		)
		self.assertEqual(transfer_entry.fg_completed_qty, 0.0)

		for item in transfer_entry.items:
			full_qty = required_qty[item.item_code]
			item.qty = full_qty if item.item_code == "_Test Item" else full_qty / 2
			item.transfer_qty = item.qty
		transfer_entry.submit()

		work_order.reload()
		transferred_qty = {row.item_code: flt(row.transferred_qty) for row in work_order.required_items}
		self.assertEqual(transferred_qty["_Test Item"], required_qty["_Test Item"])
		self.assertEqual(
			transferred_qty["_Test Item Home Desktop 100"],
			required_qty["_Test Item Home Desktop 100"] / 2,
		)
		# bottleneck fraction = 0.5 -> 0.5 * qty(2) = 1.0
		self.assertEqual(work_order.material_transferred_for_manufacturing, 1.0)

	def test_material_transferred_full_via_pick_list_flow(self):
		"""Pick-list flow with every required item fully transferred marks the work order
		as fully transferred (min fraction = 1.0)."""
		work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0
		)

		required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}

		transfer_entry = frappe.get_doc(
			make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
		)
		self.assertEqual(transfer_entry.fg_completed_qty, 0.0)
		for item in transfer_entry.items:
			item.qty = required_qty[item.item_code]
			item.transfer_qty = item.qty
		transfer_entry.submit()

		work_order.reload()
		self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)

	def test_material_transferred_ignores_hidden_precision_difference(self):
		work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100",
			target="_Test Warehouse - _TC",
			qty=10,
			basic_rate=1000.0,
		)

		precision = work_order.precision("required_qty", "required_items")
		hidden_difference = 4 / (10 ** (precision + 1))
		row = work_order.required_items[0]
		row.db_set("required_qty", flt(row.required_qty) + hidden_difference, update_modified=False)
		work_order.reload()
		required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}

		transfer_entry = frappe.get_doc(
			make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
		)
		for item in transfer_entry.items:
			item.qty = flt(required_qty[item.item_code], precision)
			item.transfer_qty = item.qty
		transfer_entry.submit()

		work_order.reload()
		self.assertEqual(
			flt(work_order.required_items[0].required_qty, precision),
			flt(work_order.required_items[0].transferred_qty, precision),
		)
		self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty)

	def test_repair_material_transfer_precision_patch(self):
		from erpnext.patches.v16_0.repair_work_order_material_transfer import (
			execute,
			get_precision_affected_work_orders,
		)

		precision = frappe.get_precision("Work Order Item", "required_qty")
		hidden_difference = 4 / (10 ** (precision + 1))
		work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
		for index, row in enumerate(work_order.required_items):
			required_qty = flt(row.required_qty) + (hidden_difference if index == 0 else 0)
			row.db_set(
				{
					"required_qty": required_qty,
					"transferred_qty": flt(required_qty, precision),
				},
				update_modified=False,
			)
		work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False)

		partial_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
		for row in partial_work_order.required_items:
			row.db_set("transferred_qty", row.required_qty, update_modified=False)
		partial_row = partial_work_order.required_items[0]
		partial_row.db_set(
			"transferred_qty",
			flt(partial_row.required_qty, precision) - (1 / (10**precision)),
			update_modified=False,
		)
		partial_work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False)

		terminal_work_orders = []
		for status in ("Stopped", "Closed", "Completed"):
			terminal_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
			for row in terminal_work_order.required_items:
				row.db_set("transferred_qty", row.required_qty, update_modified=False)
			terminal_work_order.db_set(
				{"material_transferred_for_manufacturing": 1.99, "status": status},
				update_modified=False,
			)
			terminal_work_orders.append(terminal_work_order)

		updates = get_precision_affected_work_orders()
		self.assertIn(work_order.name, updates)
		self.assertNotIn(partial_work_order.name, updates)
		for terminal_work_order in terminal_work_orders:
			self.assertNotIn(terminal_work_order.name, updates)

		execute()
		work_order.reload()
		partial_work_order.reload()
		self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty)
		self.assertEqual(partial_work_order.material_transferred_for_manufacturing, 1.99)
		for terminal_work_order in terminal_work_orders:
			terminal_work_order.reload()
			self.assertEqual(terminal_work_order.material_transferred_for_manufacturing, 1.99)

	def test_work_order_material_request_and_bom_details(self):
		from erpnext.stock.doctype.material_request.material_request import (
			make_stock_entry as mr_to_stock_entry,
		)

		work_order = make_wo_order_test_record(
			planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
		)

		mr = make_material_request(work_order.name)
		mr.schedule_date = today()
		for item in mr.items:
			item.schedule_date = today()
		mr.submit()
		self.assertEqual(mr.work_order, work_order.name)

		ste = mr_to_stock_entry(mr.name)
		self.assertEqual(ste.purpose, "Material Transfer for Manufacture")
		self.assertEqual(ste.work_order, work_order.name)
		self.assertEqual(ste.from_bom, 1.0)
		self.assertEqual(ste.bom_no, work_order.bom_no)
		self.assertEqual(ste.fg_completed_qty, 0.0)

	def test_status_in_process_when_only_one_required_item_transferred_via_material_request(self):
		"""Same bottleneck scenario as the Pick List flow, but the intermediate document is a
		Material Request created directly from the Work Order: min-fraction keeps
		material_transferred_for_manufacturing at 0, but the work order must still move to
		In Process because material is already in WIP.
		"""
		from erpnext.stock.doctype.material_request.material_request import (
			make_stock_entry as mr_to_stock_entry,
		)

		work_order = make_wo_order_test_record(
			planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0
		)

		mr = make_material_request(work_order.name)
		mr.schedule_date = today()
		# request only _Test Item; the other required item is left off this material request
		mr.items = [item for item in mr.items if item.item_code == "_Test Item"]
		for item in mr.items:
			item.schedule_date = today()
		mr.submit()

		stock_entry = frappe.get_doc(mr_to_stock_entry(mr.name))
		self.assertEqual(stock_entry.fg_completed_qty, 0.0)
		stock_entry.submit()

		work_order.reload()
		self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
		self.assertEqual(work_order.status, "In Process")

	def test_status_in_process_when_only_one_required_item_transferred_via_pick_list(self):
		"""Stock Entry created from a Pick List that picked only one of the required items:
		min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must
		still move to In Process because material is already in WIP."""
		from erpnext.manufacturing.doctype.work_order.work_order import create_pick_list
		from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry

		work_order = make_wo_order_test_record(
			planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0
		)

		pick_list = create_pick_list(work_order.name, for_qty=work_order.qty)
		# pick only _Test Item; the other required item is left out of this pick list
		pick_list.pick_manually = 1
		pick_list.locations = [loc for loc in pick_list.locations if loc.item_code == "_Test Item"]
		pick_list.save()
		pick_list.submit()

		stock_entry = frappe.get_doc(create_stock_entry(frappe.as_json(pick_list.as_dict())))
		self.assertEqual(stock_entry.fg_completed_qty, 0.0)
		stock_entry.submit()

		work_order.reload()
		self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
		self.assertEqual(work_order.status, "In Process")

	def test_backflushed_batch_raw_materials_based_on_transferred(self):
		frappe.db.set_single_value(
			"Manufacturing Settings",
			"backflush_raw_materials_based_on",
			"Material Transferred for Manufacture",
		)

		batch_item = "Test Batch MCC Keyboard"
		fg_item = "Test FG Item with Batch Raw Materials"

		ste_doc = test_stock_entry.make_stock_entry(
			item_code=batch_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True
		)

		# Inward raw materials in Stores warehouse
		ste_doc.insert()
		ste_doc.submit()
		ste_doc.load_from_db()

		batch_no = get_batch_from_bundle(ste_doc.items[0].serial_and_batch_bundle)

		wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4)
		transferred_ste_doc = frappe.get_doc(
			make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4)
		)

		transferred_ste_doc.items[0].qty = 4
		transferred_ste_doc.items[0].serial_and_batch_bundle = make_serial_batch_bundle(
			frappe._dict(
				{
					"item_code": batch_item,
					"warehouse": "Stores - _TC",
					"company": transferred_ste_doc.company,
					"qty": 4,
					"voucher_type": "Stock Entry",
					"batches": frappe._dict({batch_no: 4}),
					"posting_date": transferred_ste_doc.posting_date,
					"posting_time": transferred_ste_doc.posting_time,
					"type_of_transaction": "Outward",
					"do_not_submit": True,
				}
			)
		).name

		transferred_ste_doc.submit()
		transferred_ste_doc.load_from_db()

		# First Manufacture stock entry
		manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1))
		manufacture_ste_doc1.submit()
		manufacture_ste_doc1.load_from_db()

		# Batch no should be same as transferred Batch no
		self.assertEqual(
			get_batch_from_bundle(manufacture_ste_doc1.items[0].serial_and_batch_bundle), batch_no
		)
		self.assertEqual(manufacture_ste_doc1.items[0].qty, 1)

		# Second Manufacture stock entry
		manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2))
		manufacture_ste_doc2.submit()
		manufacture_ste_doc2.load_from_db()

		self.assertTrue(manufacture_ste_doc2.items[0].serial_and_batch_bundle)
		bundle_doc = frappe.get_doc(
			"Serial and Batch Bundle", manufacture_ste_doc2.items[0].serial_and_batch_bundle
		)

		for d in bundle_doc.entries:
			self.assertEqual(d.batch_no, batch_no)
			self.assertEqual(abs(d.qty), 2)

	def test_backflushed_serial_no_raw_materials_based_on_transferred(self):
		frappe.db.set_single_value(
			"Manufacturing Settings",
			"backflush_raw_materials_based_on",
			"Material Transferred for Manufacture",
		)

		sn_item = "Test Serial No BTT Headphone"
		fg_item = "Test FG Item with Serial No Raw Materials"

		ste_doc = test_stock_entry.make_stock_entry(
			item_code=sn_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True
		)

		# Inward raw materials in Stores warehouse
		ste_doc.submit()
		ste_doc.reload()

		serial_nos_list = sorted(get_serial_nos_from_bundle(ste_doc.items[0].serial_and_batch_bundle))

		wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4)
		transferred_ste_doc = frappe.get_doc(
			make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4)
		)

		transferred_ste_doc.items[0].serial_no = "\n".join(serial_nos_list)
		transferred_ste_doc.submit()

		# First Manufacture stock entry
		manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1))
		manufacture_ste_doc1.submit()
		manufacture_ste_doc1.reload()

		# Serial nos should be same as transferred Serial nos
		self.assertEqual(
			sorted(get_serial_nos_from_bundle(manufacture_ste_doc1.items[0].serial_and_batch_bundle)),
			serial_nos_list[0:1],
		)
		self.assertEqual(manufacture_ste_doc1.items[0].qty, 1)

		# Second Manufacture stock entry
		manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 3))

		# Serial nos should be same as transferred Serial nos
		self.assertEqual(get_serial_nos(manufacture_ste_doc2.items[0].serial_no), serial_nos_list[1:4])
		self.assertEqual(manufacture_ste_doc2.items[0].qty, 3)

	def test_backflushed_serial_no_batch_raw_materials_based_on_transferred(self):
		frappe.db.set_single_value(
			"Manufacturing Settings",
			"backflush_raw_materials_based_on",
			"Material Transferred for Manufacture",
		)

		sn_batch_item = "Test Batch Serial No WebCam"
		fg_item = "Test FG Item with Serial & Batch No Raw Materials"

		ste_doc = test_stock_entry.make_stock_entry(
			item_code=sn_batch_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True
		)

		# Inward raw materials in Stores warehouse
		ste_doc.insert()
		ste_doc.submit()
		ste_doc.load_from_db()

		serial_nos = []
		for row in ste_doc.items:
			bundle_doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)

			for d in bundle_doc.entries:
				serial_nos.append(d.serial_no)

		wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4)
		transferred_ste_doc = frappe.get_doc(
			make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4)
		)

		transferred_ste_doc.items[0].qty = 4
		transferred_ste_doc.items[0].serial_and_batch_bundle = make_serial_batch_bundle(
			frappe._dict(
				{
					"item_code": transferred_ste_doc.get("items")[0].item_code,
					"warehouse": transferred_ste_doc.get("items")[0].s_warehouse,
					"company": transferred_ste_doc.company,
					"qty": 4,
					"type_of_transaction": "Outward",
					"voucher_type": "Stock Entry",
					"serial_nos": serial_nos,
					"posting_date": transferred_ste_doc.posting_date,
					"posting_time": transferred_ste_doc.posting_time,
					"do_not_submit": True,
				}
			)
		).name

		transferred_ste_doc.submit()
		transferred_ste_doc.load_from_db()

		# First Manufacture stock entry
		manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1))
		manufacture_ste_doc1.submit()
		manufacture_ste_doc1.load_from_db()

		# Batch no & Serial Nos should be same as transferred Batch no & Serial Nos
		bundle = manufacture_ste_doc1.items[0].serial_and_batch_bundle
		self.assertTrue(bundle)

		bundle_doc = frappe.get_doc("Serial and Batch Bundle", bundle)
		for d in bundle_doc.entries:
			self.assertTrue(d.serial_no)
			self.assertTrue(d.batch_no)
			batch_no = frappe.get_cached_value("Serial No", d.serial_no, "batch_no")
			self.assertEqual(d.batch_no, batch_no)
			serial_nos.remove(d.serial_no)

		# Second Manufacture stock entry
		manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 3))
		manufacture_ste_doc2.submit()
		manufacture_ste_doc2.load_from_db()

		bundle = manufacture_ste_doc2.items[0].serial_and_batch_bundle
		self.assertTrue(bundle)

		bundle_doc = frappe.get_doc("Serial and Batch Bundle", bundle)
		for d in bundle_doc.entries:
			self.assertTrue(d.serial_no)
			self.assertTrue(d.batch_no)
			serial_nos.remove(d.serial_no)

		self.assertFalse(serial_nos)

	def test_backflushed_batch_raw_materials_based_on_transferred_autosabb(self):
		frappe.db.set_single_value(
			"Manufacturing Settings",
			"backflush_raw_materials_based_on",
			"Material Transferred for Manufacture",
		)

		batch_item = "Test Batch MCC Keyboard"
		fg_item = "Test FG Item with Batch Raw Materials"

		ste_doc = test_stock_entry.make_stock_entry(
			item_code=batch_item, target="Stores - _TC", qty=8, basic_rate=100, do_not_save=True
		)

		# Inward raw materials in Stores warehouse
		ste_doc.submit()
		ste_doc.reload()

		batch_no = get_batch_from_bundle(ste_doc.items[0].serial_and_batch_bundle)

		wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4)
		# action taken upon Start button:
		transferred_ste_doc = frappe.get_doc(
			make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4)
		)

		transferred_ste_doc.submit()
		transferred_ste_doc.reload()

		self.assertTrue(transferred_ste_doc.items[0].serial_and_batch_bundle)
		self.assertEqual(
			get_batch_from_bundle(transferred_ste_doc.items[0].serial_and_batch_bundle), batch_no
		)
		self.assertEqual(transferred_ste_doc.items[0].qty, 4.0)

		# Make additional consumption and link to WO
		test_stock_entry.make_stock_entry(
			item_code="Test Batch Battery Consumable",
			target="Stores - _TC",
			qty=8,
			basic_rate=2.33,
		)
		consume_use_doc = test_stock_entry.make_stock_entry(
			item_code="Test Batch Battery Consumable",  # consumable not linked to BOM
			source="Stores - _TC",
			qty=4,
			purpose="Material Consumption for Manufacture",
			do_not_save=True,
		)
		consume_use_doc.work_order = wo_doc.name
		consume_use_doc.fg_completed_qty = 4
		consume_use_doc.submit()
		consume_use_doc.reload()

		manufacture_ste_doc = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 4))
		mfr_items = [i.as_dict() for i in manufacture_ste_doc.items]
		manufacture_ste_doc.submit()
		manufacture_ste_doc.reload()

		self.assertTrue(len(mfr_items), 2)
		self.assertTrue(manufacture_ste_doc.items[0].serial_and_batch_bundle)
		self.assertEqual(
			get_batch_from_bundle(manufacture_ste_doc.items[0].serial_and_batch_bundle), batch_no
		)
		self.assertEqual(manufacture_ste_doc.items[0].qty, 4.0)

	def test_backflushed_serial_no_raw_materials_based_on_transferred_autosabb(self):
		frappe.db.set_single_value(
			"Manufacturing Settings",
			"backflush_raw_materials_based_on",
			"Material Transferred for Manufacture",
		)

		sn_item = "Test Serial No BTT Headphone"
		fg_item = "Test FG Item with Serial No Raw Materials"

		ste_doc = test_stock_entry.make_stock_entry(
			item_code=sn_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True
		)

		# Inward raw materials in Stores warehouse
		ste_doc.submit()
		ste_doc.reload()

		serial_nos_list = sorted(get_serial_nos_from_bundle(ste_doc.items[0].serial_and_batch_bundle))

		wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4)
		transferred_ste_doc = frappe.get_doc(
			make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4)
		)

		transferred_ste_doc.submit()
		transferred_ste_doc.reload()

		self.assertTrue(transferred_ste_doc.items[0].serial_and_batch_bundle)
		self.assertEqual(
			sorted(get_serial_nos_from_bundle(transferred_ste_doc.items[0].serial_and_batch_bundle)),
			serial_nos_list,
		)
		self.assertEqual(transferred_ste_doc.items[0].qty, 4.0)

		# Make additional consumption and link to WO
		test_stock_entry.make_stock_entry(
			item_code="Test Serial Battery Consumable",
			target="Stores - _TC",
			qty=8,
			basic_rate=3.33,
		)
		consume_use_doc = test_stock_entry.make_stock_entry(
			item_code="Test Serial Battery Consumable",  # consumable not linked to BOM
			source="Stores - _TC",
			qty=4,
			purpose="Material Consumption for Manufacture",
			do_not_save=True,
		)
		consume_use_doc.work_order = wo_doc.name
		consume_use_doc.fg_completed_qty = 4
		consume_use_doc.submit()
		consume_use_doc.reload()

		manufacture_ste_doc = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 4))
		mfr_items = [i.as_dict() for i in manufacture_ste_doc.items]
		manufacture_ste_doc.submit()
		manufacture_ste_doc.reload()

		self.assertTrue(len(mfr_items), 2)
		self.assertTrue(manufacture_ste_doc.items[0].serial_and_batch_bundle)
		self.assertEqual(
			sorted(get_serial_nos_from_bundle(manufacture_ste_doc.items[0].serial_and_batch_bundle)),
			serial_nos_list,
		)
		self.assertEqual(manufacture_ste_doc.items[0].qty, 4.0)

	def test_backflushed_serial_no_batch_raw_materials_based_on_transferred_autosabb(self):
		frappe.db.set_single_value(
			"Manufacturing Settings",
			"backflush_raw_materials_based_on",
			"Material Transferred for Manufacture",
		)

		sn_batch_item = "Test Batch Serial No WebCam"
		fg_item = "Test FG Item with Serial & Batch No Raw Materials"

		ste_doc = test_stock_entry.make_stock_entry(
			item_code=sn_batch_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True
		)

		ste_doc.submit()
		ste_doc.reload()

		serial_nos_list = sorted(get_serial_nos_from_bundle(ste_doc.items[0].serial_and_batch_bundle))
		batch_no = get_batch_from_bundle(ste_doc.items[0].serial_and_batch_bundle)

		wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4)
		transferred_ste_doc = frappe.get_doc(
			make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4)
		)

		transferred_ste_doc.submit()
		transferred_ste_doc.reload()

		self.assertTrue(transferred_ste_doc.items[0].serial_and_batch_bundle)
		self.assertEqual(
			sorted(get_serial_nos_from_bundle(transferred_ste_doc.items[0].serial_and_batch_bundle)),
			serial_nos_list,
		)
		self.assertEqual(
			get_batch_from_bundle(transferred_ste_doc.items[0].serial_and_batch_bundle), batch_no
		)
		self.assertEqual(transferred_ste_doc.items[0].qty, 4.0)

		manufacture_ste_doc = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 4))
		manufacture_ste_doc.submit()
		manufacture_ste_doc.reload()

		self.assertTrue(manufacture_ste_doc.items[0].serial_and_batch_bundle)
		self.assertEqual(
			sorted(get_serial_nos_from_bundle(manufacture_ste_doc.items[0].serial_and_batch_bundle)),
			serial_nos_list,
		)
		self.assertEqual(
			get_batch_from_bundle(manufacture_ste_doc.items[0].serial_and_batch_bundle), batch_no
		)
		self.assertEqual(manufacture_ste_doc.items[0].qty, 4.0)

		bundle = manufacture_ste_doc.items[0].serial_and_batch_bundle
		bundle_doc = frappe.get_doc("Serial and Batch Bundle", bundle)
		qty = sum(e.qty for e in bundle_doc.entries)
		self.assertEqual(qty, -4.0)

	###
	def test_non_consumed_material_return_against_work_order(self):
		frappe.db.set_single_value(
			"Manufacturing Settings",
			"backflush_raw_materials_based_on",
			"Material Transferred for Manufacture",
		)

		item = make_item(
			"Test FG Item To Test Return Case",
			{
				"is_stock_item": 1,
			},
		)

		item_code = item.name
		bom_doc = make_bom(
			item=item_code,
			source_warehouse="Stores - _TC",
			raw_materials=["Test Batch MCC Keyboard", "Test Serial No BTT Headphone"],
		)

		# Create a work order
		wo_doc = make_wo_order_test_record(production_item=item_code, qty=5)
		wo_doc.save()

		self.assertEqual(wo_doc.bom_no, bom_doc.name)

		# Transfer material for manufacture
		ste_doc = frappe.get_doc(make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 5))
		for row in ste_doc.items:
			row.qty += 2
			row.transfer_qty += 2
			test_stock_entry.make_stock_entry(
				item_code=row.item_code, target="Stores - _TC", qty=row.qty, basic_rate=100
			)

		ste_doc.save()
		ste_doc.submit()
		ste_doc.load_from_db()

		# Create a stock entry to manufacture the item
		ste_doc = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 5))
		for row in ste_doc.items:
			if row.s_warehouse and not row.t_warehouse:
				row.qty -= 2
				row.transfer_qty -= 2

			if row.serial_no:
				serial_nos = get_serial_nos(row.serial_no)
				row.serial_no = "\n".join(serial_nos[:5])

		ste_doc.save()
		ste_doc.submit()

		wo_doc.load_from_db()
		for row in wo_doc.required_items:
			self.assertEqual(row.transferred_qty, 7)
			self.assertEqual(row.consumed_qty, 5)

		self.assertEqual(wo_doc.status, "Completed")
		return_ste_doc = make_stock_return_entry(wo_doc.name)
		return_ste_doc.company = wo_doc.company
		return_ste_doc.save()

		self.assertTrue(return_ste_doc.is_return)
		for row in return_ste_doc.items:
			self.assertEqual(row.qty, 2)

	def test_workstation_type_for_work_order(self):
		prepare_data_for_workstation_type_check()

		workstation_types = ["Workstation Type 1", "Workstation Type 2", "Workstation Type 3"]
		planned_start_date = "2022-11-14 10:00:00"

		wo_order = make_wo_order_test_record(
			item="Test FG Item For Workstation Type", planned_start_date=planned_start_date, qty=2
		)

		job_cards = frappe.get_all(
			"Job Card",
			fields=[
				"`tabJob Card`.`name`",
				"`tabJob Card`.`workstation_type`",
				"`tabJob Card`.`workstation`",
				"`tabJob Card Time Log`.`from_time`",
				"`tabJob Card Time Log`.`to_time`",
				"`tabJob Card Time Log`.`time_in_mins`",
			],
			filters=[
				["Job Card", "work_order", "=", wo_order.name],
				["Job Card Time Log", "docstatus", "=", 1],
			],
			order_by="`tabJob Card`.`creation` desc",
		)

		workstations_to_check = ["Workstation 1", "Workstation 3", "Workstation 5"]
		for index, row in enumerate(job_cards):
			if index != 0:
				planned_start_date = add_to_date(planned_start_date, minutes=40)

			self.assertEqual(row.workstation_type, workstation_types[index])
			self.assertEqual(row.from_time, planned_start_date)
			self.assertEqual(row.to_time, add_to_date(planned_start_date, minutes=30))
			self.assertEqual(row.workstation, workstations_to_check[index])

		planned_start_date = "2022-11-14 10:00:00"

		wo_order = make_wo_order_test_record(
			item="Test FG Item For Workstation Type", planned_start_date=planned_start_date, qty=2
		)

		job_cards = frappe.get_all(
			"Job Card",
			fields=[
				"`tabJob Card`.`name`",
				"`tabJob Card`.`workstation_type`",
				"`tabJob Card`.`workstation`",
				"`tabJob Card Time Log`.`from_time`",
				"`tabJob Card Time Log`.`to_time`",
				"`tabJob Card Time Log`.`time_in_mins`",
			],
			filters=[
				["Job Card", "work_order", "=", wo_order.name],
				["Job Card Time Log", "docstatus", "=", 1],
			],
			order_by="`tabJob Card`.`creation` desc",
		)

		workstations_to_check = ["Workstation 2", "Workstation 4", "Workstation 6"]
		for index, row in enumerate(job_cards):
			if index != 0:
				planned_start_date = add_to_date(planned_start_date, minutes=40)

			self.assertEqual(row.workstation_type, workstation_types[index])
			self.assertEqual(row.from_time, planned_start_date)
			self.assertEqual(row.to_time, add_to_date(planned_start_date, minutes=30))
			self.assertEqual(row.workstation, workstations_to_check[index])

	def test_job_card_extra_qty(self):
		items = [
			"Test FG Item for Scrap Item Test 1",
			"Test RM Item 1 for Scrap Item Test 1",
			"Test RM Item 2 for Scrap Item Test 1",
		]

		company = "_Test Company with perpetual inventory"
		for item_code in items:
			create_item(
				item_code=item_code,
				is_stock_item=1,
				is_purchase_item=1,
				opening_stock=100,
				valuation_rate=10,
				company=company,
				warehouse="Stores - TCP1",
			)

		item = "Test FG Item for Scrap Item Test 1"
		raw_materials = ["Test RM Item 1 for Scrap Item Test 1", "Test RM Item 2 for Scrap Item Test 1"]
		if not frappe.db.get_value("BOM", {"item": item}):
			bom = make_bom(
				item=item, source_warehouse="Stores - TCP1", raw_materials=raw_materials, do_not_save=True
			)
			bom.with_operations = 1
			bom.append(
				"operations",
				{
					"operation": "_Test Operation 1",
					"workstation": "_Test Workstation 1",
					"hour_rate": 20,
					"time_in_mins": 60,
				},
			)

			bom.submit()

		wo_order = make_wo_order_test_record(
			item=item,
			company=company,
			planned_start_date=now(),
			qty=20,
		)
		job_card = frappe.db.get_value("Job Card", {"work_order": wo_order.name}, "name")
		job_card_doc = frappe.get_doc("Job Card", job_card)
		for row in job_card_doc.scheduled_time_logs:
			job_card_doc.append(
				"time_logs",
				{
					"from_time": row.from_time,
					"to_time": row.to_time,
					"time_in_mins": row.time_in_mins,
					"completed_qty": 20,
				},
			)

		job_card_doc.save()

		# Make another Job Card for the same Work Order
		job_card2 = frappe.copy_doc(job_card_doc)
		job_card2.append(
			"time_logs",
			{
				"from_time": row.from_time,
				"to_time": row.to_time,
				"time_in_mins": row.time_in_mins,
			},
		)

		job_card2.time_logs[0].completed_qty = 20

		self.assertRaises(frappe.ValidationError, job_card2.save)

		frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 100)

		job_card2 = frappe.copy_doc(job_card_doc)
		job_card2.time_logs = []
		job_card2.save()

	def test_operating_cost_account(self):
		operating_cost_account = "Test Operating Cost Account - _TC"
		company = "_Test Company"
		if not frappe.db.exists("Account", operating_cost_account):
			frappe.get_doc(
				{
					"doctype": "Account",
					"account_name": "Test Operating Cost Account",
					"account_type": "Expense Account",
					"company": company,
					"parent_account": "Expenses - _TC",
					"root_type": "Expense",
				}
			).insert()

		frappe.db.set_value("Company", company, "default_operating_cost_account", operating_cost_account)

		for item in ["TEST RM OP COST Item 1", "TEST FG OP COST Item"]:
			if not frappe.db.exists("Item", item):
				make_item(item_code=item, properties={"is_stock_item": 1})

		fg_item = "TEST FG OP COST Item"
		bom_doc = make_bom(
			item=fg_item,
			raw_materials=["TEST RM OP COST Item 1"],
			rate=150,
			with_operations=1,
			do_not_save=True,
		)

		workstation = "Test Workstation For Capacity Planning 1"
		if not frappe.db.exists("Workstation", workstation):
			make_workstation(workstation=workstation, production_capacity=1)

		operation = "Test Operation For Capacity Planning 1"
		if not frappe.db.exists("Operation", operation):
			make_operation(operation=operation, workstation=workstation)

		bom_doc.append(
			"operations",
			{"operation": operation, "time_in_mins": 60, "hour_rate": 100, "workstation": workstation},
		)

		bom_doc.save()
		bom_doc.submit()

		wo = make_wo_order_test_record(
			production_item=fg_item,
			bom_no=bom_doc.name,
			qty=1,
			skip_transfer=1,
			source_warehouse="_Test Warehouse - _TC",
		)

		job_cards = frappe.get_all("Job Card", filters={"work_order": wo.name})
		for job_card in job_cards:
			job_card_doc = frappe.get_doc("Job Card", job_card.name)
			job_card_doc.time_logs = []
			job_card_doc.append(
				"time_logs",
				{
					"from_time": now(),
					"to_time": add_to_date(now(), minutes=60),
					"time_in_mins": 60,
					"completed_qty": 1,
				},
			)

			job_card_doc.submit()

		se_doc = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 1))
		se_doc.save()

		for row in se_doc.additional_costs:
			self.assertEqual(row.expense_account, operating_cost_account)

	def test_set_op_cost_and_secondary_items_from_sub_assemblies(self):
		# Make Sub Assembly BOM 1

		frappe.db.set_single_value(
			"Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies", 1
		)

		items = {
			"Test Final FG Item": 0,
			"Test Final SF Item 1": 0,
			"Test Final SF Item 2": 0,
			"Test Final RM Item 1": 100,
			"Test Final RM Item 2": 200,
			"Test Final Scrap Item 1": 50,
			"Test Final Scrap Item 2": 60,
		}

		for item in items:
			if not frappe.db.exists("Item", item):
				item_properties = {"is_stock_item": 1, "valuation_rate": items[item]}

				(make_item(item_code=item, properties=item_properties),)

		prepare_boms_for_sub_assembly_test()

		wo_order = make_wo_order_test_record(
			production_item="Test Final FG Item",
			qty=10,
			use_multi_level_bom=1,
			skip_transfer=1,
			from_wip_warehouse=1,
		)

		se_doc = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
		se_doc.save()

		self.assertTrue(se_doc.additional_costs)
		secondary_items = []
		for item in se_doc.items:
			if item.type or item.is_legacy_scrap_item:
				secondary_items.append(item.item_code)

		self.assertEqual(
			sorted(secondary_items), sorted(["Test Final Scrap Item 1", "Test Final Scrap Item 2"])
		)
		for row in se_doc.additional_costs:
			self.assertEqual(row.amount, 3000)

		frappe.db.set_single_value(
			"Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies", 0
		)

	@ERPNextTestSuite.change_settings(
		"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1}
	)
	def test_get_rm_cost_from_consumption_entry(self):
		from erpnext.stock.doctype.stock_entry.test_stock_entry import (
			make_stock_entry as make_stock_entry_test_record,
		)

		rm = make_item(properties={"is_stock_item": 1}).name
		fg = make_item(properties={"is_stock_item": 1}).name

		make_stock_entry_test_record(
			purpose="Material Receipt",
			item_code=rm,
			target="Stores - _TC",
			qty=10,
			basic_rate=100,
		)
		make_stock_entry_test_record(
			purpose="Material Receipt",
			item_code=rm,
			target="Stores - _TC",
			qty=10,
			basic_rate=200,
		)

		bom = make_bom(item=fg, raw_materials=[rm], rate=150).name
		wo = make_wo_order_test_record(
			production_item=fg,
			bom_no=bom,
			qty=10,
		)

		mte = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
		mte.items[0].s_warehouse = "Stores - _TC"
		mte.insert().submit()

		mce = frappe.get_doc(make_stock_entry(wo.name, "Material Consumption for Manufacture", 10))
		mce.insert().submit()

		me = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
		me.insert().submit()

		valuation_rate = sum([item.valuation_rate * item.transfer_qty for item in mce.items]) / 10
		self.assertEqual(me.items[0].valuation_rate, valuation_rate)

	def test_capcity_planning_for_workstation(self):
		frappe.db.set_single_value(
			"Manufacturing Settings",
			{
				"disable_capacity_planning": 0,
				"capacity_planning_for_days": 1,
				"mins_between_operations": 10,
			},
		)

		properties = {"is_stock_item": 1, "valuation_rate": 100}
		fg_item = make_item("Test FG Item For Capacity Planning", properties).name

		rm_item = make_item("Test RM Item For Capacity Planning", properties).name

		workstation = "Test Workstation For Capacity Planning"
		if not frappe.db.exists("Workstation", workstation):
			make_workstation(workstation=workstation, production_capacity=1)

		operation = "Test Operation For Capacity Planning"
		if not frappe.db.exists("Operation", operation):
			make_operation(operation=operation, workstation=workstation)

		bom_doc = make_bom(
			item=fg_item,
			source_warehouse="Stores - _TC",
			raw_materials=[rm_item],
			with_operations=1,
			do_not_submit=True,
		)

		bom_doc.append(
			"operations",
			{"operation": operation, "time_in_mins": 1420, "hour_rate": 100, "workstation": workstation},
		)
		bom_doc.submit()

		# 1st Work Order,
		# Capacity to run parallel the operation 'Test Operation For Capacity Planning' is 2
		wo_doc = make_wo_order_test_record(
			production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1
		)

		wo_doc.submit()
		job_cards = frappe.get_all(
			"Job Card",
			filters={"work_order": wo_doc.name},
		)

		self.assertEqual(len(job_cards), 1)

		# 2nd Work Order,
		wo_doc = make_wo_order_test_record(
			production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1
		)

		wo_doc.submit()
		job_cards = frappe.get_all(
			"Job Card",
			filters={"work_order": wo_doc.name},
		)

		self.assertEqual(len(job_cards), 1)

		# 3rd Work Order, capacity is full
		wo_doc = make_wo_order_test_record(
			production_item=fg_item, qty=1, planned_start_date="2024-02-25 00:00:00", do_not_submit=1
		)

		self.assertRaises(CapacityError, wo_doc.submit)

		frappe.db.set_single_value(
			"Manufacturing Settings", {"disable_capacity_planning": 1, "mins_between_operations": 0}
		)

	def test_partial_material_consumption_with_batch(self):
		from erpnext.stock.doctype.stock_entry.test_stock_entry import (
			make_stock_entry as make_stock_entry_test_record,
		)

		frappe.db.set_single_value("Manufacturing Settings", "material_consumption", 1)
		frappe.db.set_single_value(
			"Manufacturing Settings",
			"backflush_raw_materials_based_on",
			"Material Transferred for Manufacture",
		)

		fg_item = make_item(
			"Test FG Item For Partial Material Consumption",
			{"is_stock_item": 1},
		).name

		rm_item = make_item(
			"Test RM Item For Partial Material Consumption",
			{
				"is_stock_item": 1,
				"has_batch_no": 1,
				"create_new_batch": 1,
				"batch_number_series": "TST-BATCH-PMCC-.####",
			},
		).name

		make_bom(
			item=fg_item,
			source_warehouse="Stores - _TC",
			raw_materials=[rm_item],
		)

		make_stock_entry_test_record(
			purpose="Material Receipt",
			item_code=rm_item,
			target="Stores - _TC",
			qty=10,
			basic_rate=100,
		)

		wo_order = make_wo_order_test_record(item=fg_item, qty=10)

		stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 10))
		stock_entry.submit()
		stock_entry.reload()

		batch_no = get_batch_from_bundle(stock_entry.items[0].serial_and_batch_bundle)

		stock_entry = frappe.get_doc(
			make_stock_entry(wo_order.name, "Material Consumption for Manufacture", 10)
		)

		self.assertEqual(stock_entry.items[0].batch_no, batch_no)
		self.assertEqual(stock_entry.items[0].use_serial_batch_fields, 1)

		frappe.db.set_single_value("Manufacturing Settings", "material_consumption", 0)
		frappe.db.set_single_value(
			"Manufacturing Settings",
			"backflush_raw_materials_based_on",
			"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 test_disassembly_order_with_qty_from_wo_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()

		# Disassembly via WO required_items path (no source_stock_entry)
		disassemble_qty = 4
		stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))

		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 - qty scaled from WO required_items
		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}",
		)

		# Second disassembly: explicitly linked to manufacture SE — verifies SE-linked path
		# (first disassembly auto-set source_stock_entry since there's only one manufacture entry)
		disassemble_qty_2 = 2
		stock_entry_2 = frappe.get_doc(
			make_stock_entry(
				wo.name, "Disassemble", disassemble_qty_2, source_stock_entry=se_for_manufacture.name
			)
		)
		stock_entry_2.save()
		stock_entry_2.submit()

		# All rows must trace back to se_for_manufacture
		for item in stock_entry_2.items:
			self.assertEqual(item.against_stock_entry, se_for_manufacture.name)
			self.assertTrue(item.ste_detail)

		# RM qty scaled from the manufacture SE rows
		rm_row = next((i for i in stock_entry_2.items if i.item_code == raw_item), None)
		expected_rm_qty = (bom.items[0].qty / bom.quantity) * disassemble_qty_2
		self.assertAlmostEqual(rm_row.qty, expected_rm_qty, places=3)

		wo.reload()
		self.assertEqual(wo.disassembled_qty, disassemble_qty + disassemble_qty_2)

	def test_disassembly_with_multiple_manufacture_entries(self):
		"""
		Test that disassembly does not create duplicate items when manufacturing
		is done in multiple batches (multiple manufacture stock entries), including
		secondary/scrap items.

		Scenario:
		1. Create Work Order for 10 units
		2. Transfer raw materials
		3. Manufacture in 2 parts (3 units, then 7 units) - creates 2 stock entries
		4. Create Disassembly for 4 units
		5. Verify no duplicate items in the disassembly stock entry
		"""
		# Create RM, scrap and FG item
		raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name
		raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name
		scrap_item = make_item("Test Scrap for Multi Batch Disassembly", {"is_stock_item": 1}).name
		fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name
		bom = make_bom(
			item=fg_item,
			quantity=1,
			raw_materials=[raw_item1, raw_item2],
			rm_qty=2,
			scrap_items=[scrap_item],
			scrap_qty=10,
		)

		# Create WO
		wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")

		# Ensure enough stock
		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_item1,
			purpose="Material Receipt",
			target=wo.wip_warehouse,
			qty=50,
			basic_rate=100,
		)
		make_stock_entry_test_record(
			item_code=raw_item2,
			purpose="Material Receipt",
			target=wo.wip_warehouse,
			qty=50,
			basic_rate=100,
		)

		# Transfer for manufacture
		se_for_material_transfer = frappe.get_doc(
			make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty)
		)
		for item in se_for_material_transfer.items:
			item.s_warehouse = wo.wip_warehouse
		se_for_material_transfer.save()
		se_for_material_transfer.submit()

		# First Manufacture Entry - 3 units
		se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
		se_manufacture1.submit()

		# Second Manufacture Entry - 7 units
		se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7))
		se_manufacture2.submit()

		wo.reload()
		self.assertEqual(wo.produced_qty, 10)

		# Count manufacture entries
		manufacture_entries = frappe.get_all(
			"Stock Entry",
			filters={
				"work_order": wo.name,
				"purpose": "Manufacture",
				"docstatus": 1,
			},
		)
		self.assertEqual(len(manufacture_entries), 2, "Expected 2 manufacture entries")

		# Disassembly for 4 units
		disassemble_qty = 4
		stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
		stock_entry.save()
		stock_entry.submit()

		item_counts = {}
		for item in stock_entry.items:
			item_code = item.item_code
			item_counts[item_code] = item_counts.get(item_code, 0) + 1

		# No duplicates
		duplicates = {k: v for k, v in item_counts.items() if v > 1}
		self.assertEqual(
			len(duplicates),
			0,
			f"Found duplicate items in disassembly stock entry: {duplicates}",
		)

		expected_items = 4  # FG item + 2 raw materials + 1 scrap item
		self.assertEqual(
			len(stock_entry.items),
			expected_items,
			f"Expected {expected_items} items, found {len(stock_entry.items)}",
		)

		# FG item qty
		fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
		self.assertEqual(fg_item_row.qty, disassemble_qty)

		# Secondary/Scrap item: should be taken from scrap warehouse in disassembly
		scrap_row = next((i for i in stock_entry.items if i.item_code == scrap_item), None)
		self.assertIsNotNone(scrap_row)
		self.assertEqual(scrap_row.type, "Scrap")
		self.assertTrue(scrap_row.s_warehouse)
		self.assertFalse(scrap_row.t_warehouse)
		self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse)
		# BOM has scrap_qty=10/FG but also process_loss_per=10%, so actual scrap per FG = 9
		# Total produced = 9*3 + 9*7 = 90, disassemble 4/10 → 36
		self.assertEqual(scrap_row.qty, 36)

		# RM quantities
		for bom_item in bom.items:
			expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
			rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None)
			self.assertAlmostEqual(
				rm_row.qty,
				expected_qty,
				places=3,
				msg=f"Raw material {bom_item.item_code} qty mismatch",
			)

		# -- BOM-path disassembly (no source_stock_entry, no work_order) --

		make_stock_entry_test_record(
			item_code=scrap_item,
			purpose="Material Receipt",
			target=wo.fg_warehouse,
			qty=50,
			basic_rate=10,
		)

		bom_disassemble_qty = 2
		bom_se = frappe.get_doc(
			{
				"doctype": "Stock Entry",
				"stock_entry_type": "Disassemble",
				"purpose": "Disassemble",
				"from_bom": 1,
				"bom_no": bom.name,
				"fg_completed_qty": bom_disassemble_qty,
				"from_warehouse": wo.fg_warehouse,
				"to_warehouse": wo.wip_warehouse,
				"company": wo.company,
				"posting_date": nowdate(),
				"posting_time": nowtime(),
			}
		)
		bom_se.get_items()
		bom_se.save()
		bom_se.submit()

		bom_scrap_row = next((i for i in bom_se.items if i.item_code == scrap_item), None)
		self.assertIsNotNone(bom_scrap_row, "Scrap item must appear in BOM-path disassembly")
		# Without fix 3: qty = 10 * 2 = 20; with fix 3 (process_loss_per=10%): qty = 9 * 2 = 18
		self.assertEqual(
			bom_scrap_row.qty,
			18,
			f"BOM-path disassembly must apply process_loss_per; expected 18, got {bom_scrap_row.qty}",
		)

	def test_disassembly_with_additional_rm_not_in_bom(self):
		"""
		Test that SE-linked disassembly includes additional raw materials
		that were manually added during manufacturing (not part of the BOM).

		Scenario:
		1. Create Work Order for 10 units with 2 raw materials in BOM
		2. Transfer raw materials for manufacture
		3. Manufacture in 2 parts (3 units, then 7 units)
		4. In each manufacture entry, manually add an extra consumable item
		5. Disassemble 3 units linked to first manufacture entry
		6. Verify additional RM is included with correct proportional qty from SE1
		"""
		from erpnext.stock.doctype.stock_entry.test_stock_entry import (
			make_stock_entry as make_stock_entry_test_record,
		)

		# Create RM and FG item
		raw_item1 = make_item("Test BOM Raw 1 for Additional RM Disassembly", {"is_stock_item": 1}).name
		raw_item2 = make_item("Test BOM Raw 2 for Additional RM Disassembly", {"is_stock_item": 1}).name
		additional_rm = make_item("Test Additional RM for Disassembly", {"is_stock_item": 1}).name
		fg_item = make_item("Test FG for Additional RM Disassembly", {"is_stock_item": 1}).name

		bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2)

		# Create WO
		wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")

		# Ensure enough stock
		for item in [raw_item1, raw_item2, additional_rm]:
			make_stock_entry_test_record(
				item_code=item,
				purpose="Material Receipt",
				target=wo.wip_warehouse,
				qty=100,
				basic_rate=100,
			)

		# Transfer for manufacture
		se_for_material_transfer = frappe.get_doc(
			make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty)
		)
		for item in se_for_material_transfer.items:
			item.s_warehouse = wo.wip_warehouse
		se_for_material_transfer.save()
		se_for_material_transfer.submit()

		# First Manufacture Entry - 3 units with additional RM
		se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
		se_manufacture1.append(
			"items",
			{
				"item_code": additional_rm,
				"qty": 3,  # 1 per unit
				"s_warehouse": wo.wip_warehouse,
				"is_finished_item": 0,
			},
		)
		se_manufacture1.save()
		se_manufacture1.submit()

		# Second Manufacture Entry - 7 units with additional RM
		se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7))
		se_manufacture2.append(
			"items",
			{
				"item_code": additional_rm,
				"qty": 7,  # 1 per unit
				"s_warehouse": wo.wip_warehouse,
				"is_finished_item": 0,
			},
		)
		se_manufacture2.save()
		se_manufacture2.submit()

		wo.reload()
		self.assertEqual(wo.produced_qty, 10)

		# Disassemble 3 units linked to first manufacture entry
		disassemble_qty = 3
		stock_entry = frappe.get_doc(
			make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture1.name)
		)
		stock_entry.save()
		stock_entry.submit()

		# No duplicates
		item_counts = {}
		for item in stock_entry.items:
			item_code = item.item_code
			item_counts[item_code] = item_counts.get(item_code, 0) + 1

		duplicates = {k: v for k, v in item_counts.items() if v > 1}
		self.assertEqual(
			len(duplicates),
			0,
			f"Found duplicate items in disassembly stock entry: {duplicates}",
		)

		# Additional RM should be included — qty proportional to SE1 (3 units -> 3 additional RM)
		additional_rm_row = next((i for i in stock_entry.items if i.item_code == additional_rm), None)
		self.assertIsNotNone(
			additional_rm_row,
			f"Additional raw material {additional_rm} not found in disassembly",
		)

		# SE1 had 3 additional RM for 3 manufactured units, disassembling all 3
		expected_additional_rm_qty = 3
		self.assertAlmostEqual(
			additional_rm_row.qty,
			expected_additional_rm_qty,
			places=3,
			msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}",
		)

		# BOM RM qty — scaled from SE1's rows
		for bom_item in bom.items:
			expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
			rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None)
			self.assertIsNotNone(rm_row, f"BOM raw material {bom_item.item_code} not found")
			self.assertAlmostEqual(
				rm_row.qty,
				expected_qty,
				places=3,
				msg=f"BOM raw material {bom_item.item_code} qty mismatch",
			)

		# FG qty
		fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
		self.assertEqual(fg_item_row.qty, disassemble_qty)

		# FG + 2 BOM RM + 1 additional RM = 4 items
		expected_items = 4
		self.assertEqual(
			len(stock_entry.items),
			expected_items,
			f"Expected {expected_items} items, found {len(stock_entry.items)}",
		)

		# Verify traceability
		for item in stock_entry.items:
			self.assertEqual(item.against_stock_entry, se_manufacture1.name)
			self.assertTrue(item.ste_detail)

	def test_disassembly_auto_sets_source_stock_entry(self):
		from erpnext.stock.doctype.stock_entry.test_stock_entry import (
			make_stock_entry as make_stock_entry_test_record,
		)

		raw_item = make_item("Test Raw Auto Set Disassembly", {"is_stock_item": 1}).name
		fg_item = make_item("Test FG Auto Set Disassembly", {"is_stock_item": 1}).name
		bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item], rm_qty=2)

		wo = make_wo_order_test_record(production_item=fg_item, qty=5, bom_no=bom.name, status="Not Started")

		make_stock_entry_test_record(
			item_code=raw_item, purpose="Material Receipt", target=wo.wip_warehouse, qty=50, basic_rate=100
		)

		se_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty))
		for item in se_transfer.items:
			item.s_warehouse = wo.wip_warehouse
		se_transfer.save()
		se_transfer.submit()

		se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
		se_manufacture.submit()

		# Disassemble without specifying source_stock_entry
		stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", 3))
		stock_entry.save()

		# source_stock_entry should be auto-set since only one manufacture entry
		self.assertEqual(stock_entry.source_stock_entry, se_manufacture.name)

		# All items should have against_stock_entry linked
		for item in stock_entry.items:
			self.assertEqual(item.against_stock_entry, se_manufacture.name)
			self.assertTrue(item.ste_detail)

		stock_entry.submit()

	def test_disassembly_batch_tracked_items(self):
		from erpnext.stock.doctype.batch.batch import make_batch
		from erpnext.stock.doctype.stock_entry.test_stock_entry import (
			make_stock_entry as make_stock_entry_test_record,
		)

		wip_wh = "_Test Warehouse - _TC"

		rm_item = make_item(
			"Test Batch RM for Disassembly SB",
			{
				"is_stock_item": 1,
				"has_batch_no": 1,
				"create_new_batch": 1,
				"batch_number_series": "TBRD-RM-.###",
			},
		).name
		fg_item = make_item(
			"Test Batch FG for Disassembly SB",
			{
				"is_stock_item": 1,
				"has_batch_no": 1,
				"create_new_batch": 1,
				"batch_number_series": "TBRD-FG-.###",
			},
		).name

		bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
		wo = make_wo_order_test_record(
			production_item=fg_item,
			qty=6,
			bom_no=bom.name,
			skip_transfer=1,
			source_warehouse=wip_wh,
			status="Not Started",
		)

		# Two separate RM receipts → two distinct batches (batch_1, batch_2)
		rm_receipt_1 = make_stock_entry_test_record(
			item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
		)
		rm_batch_1 = get_batch_from_bundle(
			frappe.db.get_value(
				"Stock Entry Detail",
				{"parent": rm_receipt_1.name, "item_code": rm_item},
				"serial_and_batch_bundle",
			)
		)

		rm_receipt_2 = make_stock_entry_test_record(
			item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
		)
		rm_batch_2 = get_batch_from_bundle(
			frappe.db.get_value(
				"Stock Entry Detail",
				{"parent": rm_receipt_2.name, "item_code": rm_item},
				"serial_and_batch_bundle",
			)
		)

		self.assertNotEqual(rm_batch_1, rm_batch_2, "Two receipts must create two distinct RM batches")

		fg_batch_1 = make_batch(frappe._dict(item=fg_item))
		fg_batch_2 = make_batch(frappe._dict(item=fg_item))

		# Manufacture entry 1 — 3 FG using batch_1 RM/FG
		se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
		for row in se_manufacture_1.items:
			if row.item_code == rm_item:
				row.batch_no = rm_batch_1
				row.use_serial_batch_fields = 1
			elif row.item_code == fg_item:
				row.batch_no = fg_batch_1
				row.use_serial_batch_fields = 1
		se_manufacture_1.save()
		se_manufacture_1.submit()

		# Manufacture entry 2 — 3 FG using batch_2 RM/FG
		se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
		for row in se_manufacture_2.items:
			if row.item_code == rm_item:
				row.batch_no = rm_batch_2
				row.use_serial_batch_fields = 1
			elif row.item_code == fg_item:
				row.batch_no = fg_batch_2
				row.use_serial_batch_fields = 1
		se_manufacture_2.save()
		se_manufacture_2.submit()

		# Disassemble 2 units from SE_1 only — must use SE_1's batches, not SE_2's
		disassemble_qty = 2
		stock_entry = frappe.get_doc(
			make_stock_entry(
				wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
			)
		)
		stock_entry.save()
		stock_entry.submit()

		# FG row: must use fg_batch_1 exclusively (fg_batch_2 must not appear)
		fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
		self.assertIsNotNone(fg_row)
		self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
		self.assertEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_1)
		self.assertNotEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_2)

		# RM row: must use rm_batch_1 exclusively (rm_batch_2 must not appear)
		rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
		self.assertIsNotNone(rm_row)
		self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
		self.assertEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_1)
		self.assertNotEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_2)

		# RM qty: 2 FG disassembled x 2 RM per FG = 4
		self.assertAlmostEqual(rm_row.qty, 4.0, places=3)

	def test_disassembly_serial_tracked_items(self):
		from frappe.model.naming import make_autoname

		from erpnext.stock.doctype.stock_entry.test_stock_entry import (
			make_stock_entry as make_stock_entry_test_record,
		)

		wip_wh = "_Test Warehouse - _TC"

		rm_item = make_item(
			"Test Serial RM for Disassembly SB",
			{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-RM-.####"},
		).name
		fg_item = make_item(
			"Test Serial FG for Disassembly SB",
			{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-FG-.####"},
		).name

		bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
		wo = make_wo_order_test_record(
			production_item=fg_item,
			qty=6,
			bom_no=bom.name,
			skip_transfer=1,
			source_warehouse=wip_wh,
			status="Not Started",
		)

		# Two separate RM receipts → two disjoint sets of serial numbers
		rm_receipt_1 = make_stock_entry_test_record(
			item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
		)
		rm_serials_1 = get_serial_nos_from_bundle(
			frappe.db.get_value(
				"Stock Entry Detail",
				{"parent": rm_receipt_1.name, "item_code": rm_item},
				"serial_and_batch_bundle",
			)
		)
		self.assertEqual(len(rm_serials_1), 6)

		rm_receipt_2 = make_stock_entry_test_record(
			item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
		)
		rm_serials_2 = get_serial_nos_from_bundle(
			frappe.db.get_value(
				"Stock Entry Detail",
				{"parent": rm_receipt_2.name, "item_code": rm_item},
				"serial_and_batch_bundle",
			)
		)
		self.assertEqual(len(rm_serials_2), 6)
		self.assertFalse(
			set(rm_serials_1) & set(rm_serials_2), "Two receipts must produce disjoint RM serial sets"
		)

		# Pre-generate two sets of FG serial numbers
		series = frappe.db.get_value("Item", fg_item, "serial_no_series")
		fg_serials_1 = [make_autoname(series) for _ in range(3)]
		fg_serials_2 = [make_autoname(series) for _ in range(3)]

		# Manufacture entry 1 — consumes rm_serials_1, produces fg_serials_1
		se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
		for row in se_manufacture_1.items:
			if row.item_code == rm_item:
				row.serial_no = "\n".join(rm_serials_1)
				row.use_serial_batch_fields = 1
			elif row.item_code == fg_item:
				row.serial_no = "\n".join(fg_serials_1)
				row.use_serial_batch_fields = 1
		se_manufacture_1.save()
		se_manufacture_1.submit()

		# Manufacture entry 2 — consumes rm_serials_2, produces fg_serials_2
		se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
		for row in se_manufacture_2.items:
			if row.item_code == rm_item:
				row.serial_no = "\n".join(rm_serials_2)
				row.use_serial_batch_fields = 1
			elif row.item_code == fg_item:
				row.serial_no = "\n".join(fg_serials_2)
				row.use_serial_batch_fields = 1
		se_manufacture_2.save()
		se_manufacture_2.submit()

		# Disassemble 2 units from SE_1 only — must use SE_1's serials, not SE_2's
		disassemble_qty = 2
		stock_entry = frappe.get_doc(
			make_stock_entry(
				wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
			)
		)
		stock_entry.save()
		stock_entry.submit()

		# FG row: 2 serials consumed — must be subset of fg_serials_1, disjoint from fg_serials_2
		fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
		self.assertIsNotNone(fg_row)
		self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
		fg_dasm_serials = get_serial_nos_from_bundle(fg_row.serial_and_batch_bundle)
		self.assertEqual(len(fg_dasm_serials), disassemble_qty)
		self.assertTrue(set(fg_dasm_serials).issubset(set(fg_serials_1)))
		self.assertFalse(
			set(fg_dasm_serials) & set(fg_serials_2), "Disassembly must not use SE_2's FG serials"
		)

		# RM row: 4 serials returned (2 FG x 2 RM each) — subset of rm_serials_1, disjoint from rm_serials_2
		rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
		self.assertIsNotNone(rm_row)
		self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
		rm_dasm_serials = get_serial_nos_from_bundle(rm_row.serial_and_batch_bundle)
		self.assertEqual(len(rm_dasm_serials), disassemble_qty * 2)
		self.assertTrue(set(rm_dasm_serials).issubset(set(rm_serials_1)))
		self.assertFalse(
			set(rm_dasm_serials) & set(rm_serials_2), "Disassembly must not use SE_2's RM serials"
		)

	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)

		fg_item = "Test FG Item For Component Validation for alternate item"
		source_warehouse = "Stores - _TC"
		raw_materials = ["Test Component Validation RM Item 112", "Test Component Validation RM Item 22"]
		alternate_item = ["Alternate Test Component Validation RM Item 1"]

		make_item(fg_item, {"is_stock_item": 1})
		for item in raw_materials + alternate_item:
			make_item(item, {"is_stock_item": 1, "allow_alternative_item": 1})
			test_stock_entry.make_stock_entry(
				item_code=item,
				target=source_warehouse,
				qty=10,
				basic_rate=100,
			)

		frappe.get_doc(
			{
				"doctype": "Item Alternative",
				"item_code": raw_materials[0],
				"alternative_item_code": alternate_item[0],
				"two_way": 1,
			}
		).insert()

		make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=raw_materials)

		wo = make_wo_order_test_record(
			item=fg_item,
			qty=10,
			source_warehouse=source_warehouse,
		)

		transfer_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
		transfer_entry.save()
		transfer_entry.items[0].item_code = alternate_item[0]
		transfer_entry.items[0].original_item = raw_materials[0]
		transfer_entry.submit()

		self.assertTrue(transfer_entry.docstatus == 1)

		manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
		manufacture_entry.save()
		self.assertTrue(manufacture_entry.items[0].item_code == alternate_item[0])
		self.assertTrue(manufacture_entry.items[0].original_item == raw_materials[0])

		manufacture_entry.submit()

		frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 0)

	def test_components_qty_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)

		fg_item = "Test FG Item For Component Validation"
		source_warehouse = "Stores - _TC"
		raw_materials = ["Test Component Validation RM Item 1", "Test Component Validation 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=10,
				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=10,
			source_warehouse=source_warehouse,
		)

		transfer_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
		transfer_entry.save()
		for row in transfer_entry.items:
			row.qty = 5

		self.assertRaises(frappe.ValidationError, transfer_entry.save)

		transfer_entry.reload()
		for row in transfer_entry.items:
			self.assertEqual(row.qty, 10)

		transfer_entry.submit()

		manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
		manufacture_entry.save()
		for row in manufacture_entry.items:
			if not row.s_warehouse:
				continue

			row.qty = 5

		self.assertRaises(frappe.ValidationError, manufacture_entry.save)
		manufacture_entry.reload()
		manufacture_entry.submit()

		frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 0)

	def test_components_as_per_bom_for_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)

		fg_item = "Test FG Item For Component Validation 1"
		source_warehouse = "Stores - _TC"
		raw_materials = ["Test Component Validation RM Item 11", "Test Component Validation RM Item 12"]

		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=10,
				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=10,
			source_warehouse=source_warehouse,
		)

		transfer_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
		transfer_entry.save()
		transfer_entry.remove(transfer_entry.items[0])

		self.assertRaises(frappe.ValidationError, transfer_entry.save)

		transfer_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
		transfer_entry.save()
		transfer_entry.submit()

		manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
		manufacture_entry.save()

		manufacture_entry.remove(manufacture_entry.items[0])

		self.assertRaises(frappe.ValidationError, manufacture_entry.save)
		manufacture_entry.delete()

		manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
		manufacture_entry.save()
		manufacture_entry.submit()

		frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 0)

	def test_wip_skip(self):
		wo = make_wo_order_test_record(
			item="_Test FG Item",
			qty=10,
			source_warehouse="_Test Warehouse - _TC",
			wip_warehouse="Stores - _TC",
		)
		manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
		self.assertEqual(manufacture_entry.items[0].s_warehouse, "Stores - _TC")

		wo = make_wo_order_test_record(
			item="_Test FG Item",
			qty=10,
			source_warehouse="_Test Warehouse - _TC",
			wip_warehouse="Stores - _TC",
			skip_transfer=1,
		)
		manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
		self.assertEqual(manufacture_entry.items[0].s_warehouse, "_Test Warehouse - _TC")

		wo = make_wo_order_test_record(
			item="_Test FG Item",
			qty=10,
			source_warehouse="_Test Warehouse - _TC",
			wip_warehouse="Stores - _TC",
			skip_transfer=1,
			from_wip_warehouse=1,
		)
		manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
		self.assertEqual(manufacture_entry.items[0].s_warehouse, "Stores - _TC")

	def test_serial_no_status_for_stock_entry(self):
		items = {
			"Finished Good Test Item 1": {"is_stock_item": 1},
			"_Test RM Item with Serial No": {
				"is_stock_item": 1,
				"has_serial_no": 1,
				"serial_no_series": "SN-FCG-NO-.####",
			},
		}
		for item, properties in items.items():
			make_item(item, properties)

		fg_item = "Finished Good Test Item 1"
		rec_se = test_stock_entry.make_stock_entry(
			item_code="_Test RM Item with Serial No", target="_Test Warehouse - _TC", qty=4, basic_rate=100
		)

		if not frappe.db.get_value("BOM", {"item": fg_item, "docstatus": 1}):
			bom = make_bom(
				item=fg_item,
				rate=1000,
				raw_materials=["_Test RM Item with Serial No"],
				do_not_save=True,
			)
			bom.rm_cost_as_per = "Price List"  # non stock item won't have valuation rate
			bom.buying_price_list = "_Test Price List India"
			bom.currency = "INR"
			bom.save()

		wo = make_wo_order_test_record(
			production_item=fg_item, skip_transfer=1, source_warehouse="_Test Warehouse - _TC"
		)

		ste = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 4))
		ste.items[0].use_serial_batch_fields = 1
		ste.items[0].serial_no = "\n".join(
			get_serial_nos_from_bundle(rec_se.items[0].serial_and_batch_bundle)
		)
		ste.insert()
		ste.submit()

		ste.reload()
		serial_nos = get_serial_nos_from_bundle(ste.items[0].serial_and_batch_bundle)
		for row in serial_nos:
			status = frappe.db.get_value("Serial No", row, "status")
			self.assertEqual(status, "Consumed")

	def test_stock_reservation_for_serialized_raw_material(self):
		from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
			make_stock_entry as make_stock_entry_test_record,
		)

		production_item = "Test Stock Reservation FG 1"
		rm_item = "Test Stock Reservation RM 1"
		source_warehouse = "Stores - _TC"

		make_item(production_item, {"is_stock_item": 1})
		make_item(rm_item, {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TST-SER-RES-.###"})

		bom = make_bom(
			item=production_item,
			source_warehouse=source_warehouse,
			raw_materials=[rm_item],
			operating_cost_per_bom_quantity=100,
			do_not_submit=True,
		)

		for row in bom.exploded_items:
			make_stock_entry_test_record(
				item_code=row.item_code,
				target=source_warehouse,
				qty=10,
				basic_rate=100,
			)

		wo = make_wo_order_test_record(
			item=production_item,
			qty=10,
			reserve_stock=1,
			source_warehouse=source_warehouse,
		)

		self.assertTrue(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo.name}))

		wo1 = make_wo_order_test_record(
			item=production_item,
			qty=10,
			reserve_stock=1,
			source_warehouse=source_warehouse,
		)

		self.assertFalse(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo1.name}))

		transfer_entry = frappe.get_doc(make_stock_entry(wo1.name, "Material Transfer for Manufacture", 10))
		transfer_entry.save()

		self.assertRaises(frappe.ValidationError, transfer_entry.submit)

	@ERPNextTestSuite.change_settings(
		"Buying Settings",
		{"backflush_raw_materials_of_subcontract_based_on": "Material Transferred for Subcontract"},
	)
	def test_send_to_subcontractor_can_consume_work_order_reserved_stock(self):
		from erpnext.buying.doctype.purchase_order.purchase_order import make_subcontracting_order
		from erpnext.controllers.subcontracting_controller import make_rm_stock_entry
		from erpnext.manufacturing.doctype.job_card.job_card import make_subcontracting_po
		from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
			make_stock_entry as make_stock_entry_test_record,
		)
		from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse

		company = "_Test Company"
		warehouse = "Stores - _TC"
		supplier_warehouse = create_warehouse("Test S2S Supplier WH", company=company)

		fabric = make_item("Test S2S Fabric", {"is_stock_item": 1}).name
		stitched = make_item("Test S2S Stitched Shirt", {"is_stock_item": 1}).name
		tshirt = make_item("Test S2S T-Shirt", {"is_stock_item": 1, "is_sub_contracted_item": 1}).name
		service_item = make_item("Test S2S Ironing Service", {"is_stock_item": 0}).name

		# Semi-FG BOM: Stitched Shirt from Fabric.
		sfg_bom = frappe.new_doc("BOM")
		sfg_bom.company = company
		sfg_bom.item = stitched
		sfg_bom.quantity = 1
		sfg_bom.append("items", {"item_code": fabric, "qty": 1})
		sfg_bom.insert()
		sfg_bom.submit()

		# Subcontracting BOM: how to make the final T-Shirt at the supplier (consuming Stitched Shirt).
		tshirt_from_stitched = frappe.new_doc("BOM")
		tshirt_from_stitched.company = company
		tshirt_from_stitched.item = tshirt
		tshirt_from_stitched.quantity = 1
		tshirt_from_stitched.append("items", {"item_code": stitched, "qty": 1})
		tshirt_from_stitched.insert()
		tshirt_from_stitched.submit()

		if not frappe.db.exists("Subcontracting BOM", {"finished_good": tshirt}):
			frappe.get_doc(
				{
					"doctype": "Subcontracting BOM",
					"finished_good": tshirt,
					"finished_good_qty": 1,
					"service_item": service_item,
					"service_item_qty": 1,
					"finished_good_bom": tshirt_from_stitched.name,
					"is_active": 1,
				}
			).insert()

		if not frappe.db.exists("Workstation", "Test S2S Workstation"):
			make_workstation(workstation="Test S2S Workstation", production_capacity=1)
		for op in ("Test S2S Stitching", "Test S2S Ironing"):
			if not frappe.db.exists("Operation", op):
				make_operation(operation=op, workstation="Test S2S Workstation")

		# Final BOM for T-Shirt: internal Stitching op (produces Stitched Shirt) + subcontracted Ironing.
		fg_bom = frappe.new_doc("BOM")
		fg_bom.company = company
		fg_bom.item = tshirt
		fg_bom.quantity = 1
		fg_bom.with_operations = 1
		fg_bom.track_semi_finished_goods = 1
		fg_bom.append("items", {"item_code": fabric, "qty": 1})
		fg_bom.append(
			"operations",
			{
				"operation": "Test S2S Stitching",
				"workstation": "Test S2S Workstation",
				"finished_good": stitched,
				"finished_good_qty": 1,
				"bom_no": sfg_bom.name,
				"time_in_mins": 60,
				"sequence_id": 1,
			},
		)
		fg_bom.append(
			"operations",
			{
				"operation": "Test S2S Ironing",
				"workstation": "Test S2S Workstation",
				"finished_good": tshirt,
				"finished_good_qty": 1,
				"is_final_finished_good": 1,
				"is_subcontracted": 1,
				"bom_no": tshirt_from_stitched.name,
				"time_in_mins": 60,
				"sequence_id": 2,
			},
		)
		fg_bom.append("items", {"item_code": stitched, "qty": 1, "operation_row_id": 2})
		fg_bom.insert()
		fg_bom.submit()

		make_stock_entry_test_record(item_code=fabric, target=warehouse, qty=10, basic_rate=100)

		wo = make_wo_order_test_record(
			production_item=tshirt,
			qty=10,
			bom_no=fg_bom.name,
			reserve_stock=1,
			skip_transfer=1,
			source_warehouse=warehouse,
			wip_warehouse=warehouse,
			fg_warehouse=warehouse,
			do_not_save=True,
		)
		wo.operations[0].time_in_mins = 60
		wo.operations[1].time_in_mins = 60
		wo.save()
		wo.submit()

		# Complete the internal Stitching job card -> Stitched Shirt is produced into WIP and reserved.
		stitching_jc = frappe.get_doc(
			"Job Card",
			frappe.db.get_value("Job Card", {"work_order": wo.name, "operation": "Test S2S Stitching"}),
		)
		stitching_jc.append(
			"time_logs",
			{
				"from_time": "2024-01-01 08:00:00",
				"to_time": "2024-01-01 09:00:00",
				"completed_qty": stitching_jc.for_quantity,
			},
		)
		stitching_jc.submit()

		manufacturing_entry = frappe.get_doc(stitching_jc.make_stock_entry_for_semi_fg_item())
		manufacturing_entry.submit()

		sre_name = frappe.db.get_value(
			"Stock Reservation Entry",
			{"voucher_no": wo.name, "item_code": stitched, "warehouse": warehouse, "docstatus": 1},
		)
		self.assertTrue(sre_name, "Work Order should have reserved the semi-finished good")

		# Subcontract the Ironing operation: Job Card -> Subcontracting PO -> Subcontracting Order.
		ironing_jc = frappe.db.get_value("Job Card", {"work_order": wo.name, "operation": "Test S2S Ironing"})
		po = frappe.get_doc(make_subcontracting_po(ironing_jc))
		po.supplier = "_Test Supplier"
		po.supplier_warehouse = supplier_warehouse
		po.schedule_date = nowdate()
		for item in po.items:
			item.schedule_date = nowdate()
		po.insert()
		po.submit()

		sco = make_subcontracting_order(po.name)
		sco.supplier_warehouse = supplier_warehouse
		for item in sco.supplied_items:
			item.reserve_warehouse = warehouse
		sco.insert()
		sco.submit()

		# Transfer the reserved Stitched Shirt to the subcontractor. This must NOT raise
		# NegativeStockError ("reserved for other transactions").
		ste = frappe.new_doc("Stock Entry").update(make_rm_stock_entry(sco.name))
		ste.insert()
		ste.submit()

		# The reservation is freed: transferred_qty == sent qty and the SRE is Closed.
		sre = frappe.get_doc("Stock Reservation Entry", sre_name)
		self.assertEqual(sre.transferred_qty, 10)
		self.assertEqual(sre.status, "Closed")

		# Cancelling the transfer restores the reservation.
		ste.cancel()
		sre.reload()
		self.assertEqual(sre.transferred_qty, 0)
		self.assertEqual(sre.status, "Reserved")

	def test_stock_reservation_for_batched_raw_material(self):
		from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
			make_stock_entry as make_stock_entry_test_record,
		)

		production_item = "Test Stock Reservation FG 2"
		rm_item = "Test Stock Reservation RM 2"
		source_warehouse = "Stores - _TC"

		make_item(production_item, {"is_stock_item": 1})
		make_item(
			rm_item,
			{
				"is_stock_item": 1,
				"has_batch_no": 1,
				"batch_number_series": "TST-BATCH-RES-.###",
				"create_new_batch": 1,
			},
		)

		bom = make_bom(
			item=production_item,
			source_warehouse=source_warehouse,
			raw_materials=[rm_item],
			operating_cost_per_bom_quantity=100,
			do_not_submit=True,
		)

		for row in bom.exploded_items:
			make_stock_entry_test_record(
				item_code=row.item_code,
				target=source_warehouse,
				qty=10,
				basic_rate=100,
			)

		wo = make_wo_order_test_record(
			item=production_item,
			qty=10,
			reserve_stock=1,
			source_warehouse=source_warehouse,
		)

		self.assertTrue(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo.name}))

		wo1 = make_wo_order_test_record(
			item=production_item,
			qty=10,
			reserve_stock=1,
			source_warehouse=source_warehouse,
		)

		self.assertFalse(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo1.name}))

		transfer_entry = frappe.get_doc(make_stock_entry(wo1.name, "Material Transfer for Manufacture", 10))
		transfer_entry.save()

		self.assertRaises(frappe.ValidationError, transfer_entry.submit)

	@ERPNextTestSuite.change_settings(
		"Stock Settings",
		{"enable_stock_reservation": 1, "allow_partial_reservation": 1},
	)
	def test_partial_reservation_records_full_voucher_qty(self):
		# Regression: a short reservation must keep voucher_qty as the full requirement.
		from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
			make_stock_entry as make_stock_entry_test_record,
		)

		production_item = "Test Partial Reservation FG"
		rm_item = "Test Partial Reservation RM"
		source_warehouse = "Stores - _TC"

		make_item(production_item, {"is_stock_item": 1})
		make_item(rm_item, {"is_stock_item": 1})

		make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])

		# Only 6 units on hand while the Work Order needs 10.
		make_stock_entry_test_record(item_code=rm_item, target=source_warehouse, qty=6, basic_rate=100)

		wo = make_wo_order_test_record(
			item=production_item,
			qty=10,
			reserve_stock=1,
			source_warehouse=source_warehouse,
		)

		sre = frappe.get_all(
			"Stock Reservation Entry",
			filters={"voucher_no": wo.name, "docstatus": 1},
			fields=["voucher_qty", "reserved_qty", "status"],
		)
		self.assertEqual(len(sre), 1)
		self.assertEqual(sre[0].reserved_qty, 6)
		self.assertEqual(sre[0].voucher_qty, 10)
		self.assertEqual(sre[0].status, "Partially Reserved")

	def test_auto_stock_reservation_for_batched_raw_material(self):
		from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
			make_stock_entry as make_stock_entry_test_record,
		)

		frappe.db.set_single_value("Stock Settings", "auto_reserve_serial_and_batch", 1)

		production_item = "Test Stock Reservation FG 3"
		rm_item = "Test Stock Reservation RM 3"
		source_warehouse = "Stores - _TC"

		make_item(production_item, {"is_stock_item": 1})
		make_item(
			rm_item,
			{
				"is_stock_item": 1,
				"has_batch_no": 1,
				"batch_number_series": "TST-BATCH-RES-.###",
				"create_new_batch": 1,
			},
		)

		bom = make_bom(
			item=production_item,
			source_warehouse=source_warehouse,
			raw_materials=[rm_item],
			operating_cost_per_bom_quantity=100,
			do_not_submit=True,
		)

		itemwise_batches = frappe._dict()
		for row in bom.exploded_items:
			se = make_stock_entry_test_record(
				item_code=row.item_code,
				target=source_warehouse,
				qty=10,
				basic_rate=100,
			)

			itemwise_batches[row.item_code] = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)

		wo = make_wo_order_test_record(
			item=production_item,
			qty=10,
			reserve_stock=1,
			source_warehouse=source_warehouse,
		)

		self.assertTrue(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo.name}))

		for row in frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo.name}):
			reservation_entry = frappe.get_doc("Stock Reservation Entry", row.name)
			self.assertTrue(reservation_entry.has_batch_no)
			self.assertTrue(reservation_entry.sb_entries)

		for row in bom.exploded_items:
			make_stock_entry_test_record(
				item_code=row.item_code,
				target=source_warehouse,
				qty=10,
				basic_rate=100,
			)

		transfer_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
		transfer_entry.save()
		transfer_entry.submit()

		for row in transfer_entry.items:
			batch_no = get_batch_from_bundle(row.serial_and_batch_bundle)
			self.assertEqual(batch_no, itemwise_batches[row.item_code])

	def test_work_order_valuation_auto_pick(self):
		fg_item = "Test FG Item For Non Transfer Item Batch"
		rm_item = "Test RM Item For Non Transfer Item Batch"

		make_item(fg_item, {"is_stock_item": 1})
		make_item(
			rm_item,
			{
				"is_stock_item": 1,
				"has_batch_no": 1,
				"create_new_batch": 1,
				"batch_number_series": "TST-BATCH-NTI-.###",
			},
		)

		source_warehouse = "_Test Warehouse - _TC"
		wip_warehouse = "Stores - _TC"
		finished_goods_warehouse = create_warehouse("_Test Finished Goods Warehouse", company="_Test Company")

		batches = make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse)

		if not frappe.db.get_value("BOM", {"item": fg_item}):
			make_bom(item=fg_item, raw_materials=[rm_item])

		wo = make_wo_order_test_record(
			item=fg_item,
			qty=5,
			source_warehouse=source_warehouse,
			wip_warehouse=wip_warehouse,
			fg_warehouse=finished_goods_warehouse,
		)

		stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 5))
		stock_entry.items[0].batch_no = batches[1]
		stock_entry.items[0].use_serial_batch_fields = 1
		stock_entry.submit()
		stock_entry.reload()

		self.assertEqual(stock_entry.items[0].valuation_rate, 200)

		original_value = frappe.db.get_single_value(
			"Stock Settings", "auto_create_serial_and_batch_bundle_for_outward"
		)
		original_based_on = frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on")

		frappe.db.set_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward", 1)
		frappe.db.set_single_value("Stock Settings", "pick_serial_and_batch_based_on", "Expiry")

		stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 5))
		stock_entry.items[0].use_serial_batch_fields = 1
		stock_entry.submit()
		stock_entry.reload()

		batch_no = get_batch_from_bundle(stock_entry.items[0].serial_and_batch_bundle)
		self.assertEqual(batch_no, batches[1])
		self.assertEqual(stock_entry.items[0].valuation_rate, 200)
		self.assertEqual(stock_entry.items[1].valuation_rate, 200)

		frappe.db.set_single_value(
			"Stock Settings", "auto_create_serial_and_batch_bundle_for_outward", original_value
		)
		frappe.db.set_single_value("Stock Settings", "pick_serial_and_batch_based_on", original_based_on)

	def test_operations_time_planning_calculation(self):
		from erpnext.manufacturing.doctype.routing.test_routing import create_routing, setup_operations

		operations = [
			{"operation": "Test Operation A", "workstation": "Test Workstation A", "time_in_mins": 1},
			{"operation": "Test Operation B", "workstation": "Test Workstation A", "time_in_mins": 4},
			{"operation": "Test Operation C", "workstation": "Test Workstation A", "time_in_mins": 3},
			{"operation": "Test Operation D", "workstation": "Test Workstation A", "time_in_mins": 2},
		]
		setup_operations(operations)
		routing_doc = create_routing(routing_name="Testing Route", operations=operations)
		bom = make_bom(
			item="_Test FG Item", raw_materials=["_Test Item"], with_operations=1, routing=routing_doc.name
		)

		wo = make_wo_order_test_record(
			item="_Test FG Item",
			bom_no=bom.name,
			qty=5,
			source_warehouse="_Test Warehouse 1 - _TC",
			skip_transfer=1,
			fg_warehouse="_Test Warehouse 2 - _TC",
		)

		wo = frappe.copy_doc(wo)
		wo.operations[3].sequence_id = None

		# Test 1 : If any one operation does not have sequence ID then error will be thrown
		self.assertRaises(frappe.ValidationError, wo.submit)

		for op in wo.operations:
			op.sequence_id = None
		wo.submit()

		# Test 2 : If none of the operations have sequence ID then they will be sequenced as per their idx
		for op in wo.operations:
			self.assertEqual(op.sequence_id, op.idx)

		wo = frappe.copy_doc(wo)
		wo.operations[0].sequence_id = 2

		# Test 3 : Sequence IDs should not miss the correct sequence of numbers
		self.assertRaises(frappe.ValidationError, wo.submit)

		wo.operations[1].sequence_id = 1

		# Test 4 : Sequence IDs should be in the correct ascending order
		self.assertRaises(frappe.ValidationError, wo.submit)

		workstation = frappe.get_doc("Workstation", "Test Workstation A")
		workstation.production_capacity = 4
		workstation.save()
		wo = frappe.copy_doc(wo)
		wo.operations[0].sequence_id = 1
		wo.operations[1].sequence_id = 2
		wo.operations[2].sequence_id = 2
		wo.operations[3].sequence_id = 3
		wo.submit()

		# Test 5 : If two operations have the same sequence ID then the next operation will start 10 mins after the longest previous operation ends
		self.assertEqual(
			wo.operations[3].planned_start_time, add_to_date(wo.operations[1].planned_end_time, minutes=10)
		)

	def test_allow_additional_material_transfer(self):
		from erpnext.stock.doctype.stock_entry.test_stock_entry import (
			make_stock_entry as make_stock_entry_test_record,
		)

		frappe.db.set_single_value("Manufacturing Settings", "transfer_extra_materials_percentage", 50)
		wo_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
		for row in wo_order.required_items:
			make_stock_entry_test_record(
				item_code=row.item_code,
				target=row.source_warehouse,
				qty=row.required_qty * 2,
				basic_rate=100,
			)

		stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 2))
		stock_entry.insert()
		stock_entry.submit()

		wo_order.reload()
		self.assertEqual(wo_order.material_transferred_for_manufacturing, 2)

		stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 1))
		stock_entry.insert()
		stock_entry.submit()

		wo_order.reload()
		self.assertEqual(wo_order.material_transferred_for_manufacturing, 3)
		frappe.db.set_single_value("Manufacturing Settings", "transfer_extra_materials_percentage", 0)

	def test_req_qty_clamping_in_manufacture_entry(self):
		from erpnext.stock.doctype.stock_entry.test_stock_entry import (
			make_stock_entry as make_stock_entry_test_record,
		)

		fg_item = "Test Unconsumed RM FG Item"
		rm_item_1 = "Test Unconsumed RM Item 1"
		rm_item_2 = "Test Unconsumed RM Item 2"

		source_warehouse = "_Test Warehouse - _TC"
		wip_warehouse = "Stores - _TC"
		fg_warehouse = create_warehouse("_Test Finished Goods Warehouse", company="_Test Company")

		make_item(fg_item, {"is_stock_item": 1})
		make_item(rm_item_1, {"is_stock_item": 1})
		make_item(rm_item_2, {"is_stock_item": 1})

		# create a BOM: 1 FG = 1 RM1 + 1 RM2
		bom = make_bom(
			item=fg_item,
			source_warehouse=source_warehouse,
			raw_materials=[rm_item_1, rm_item_2],
			operating_cost_per_bom_quantity=1,
			do_not_submit=True,
		)

		for row in bom.exploded_items:
			make_stock_entry_test_record(
				item_code=row.item_code,
				target=source_warehouse,
				qty=100,
				basic_rate=100,
			)

		wo = make_wo_order_test_record(
			item=fg_item,
			qty=50,
			source_warehouse=source_warehouse,
			wip_warehouse=wip_warehouse,
		)
		wo.submit()

		# first partial transfer & manufacture (6 units)
		se_transfer_1 = frappe.get_doc(
			make_stock_entry(wo.name, "Material Transfer for Manufacture", 6, wip_warehouse)
		)
		se_transfer_1.insert()
		se_transfer_1.submit()

		stock_entry_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 6, fg_warehouse))

		# remove rm_2 from the items to simulate unconsumed RM scenario
		stock_entry_1.items = [row for row in stock_entry_1.items if row.item_code != rm_item_2]
		stock_entry_1.save()
		stock_entry_1.submit()

		wo.reload()

		se_transfer_2 = frappe.get_doc(
			make_stock_entry(wo.name, "Material Transfer for Manufacture", 20, wip_warehouse)
		)
		se_transfer_2.insert()
		se_transfer_2.submit()

		stock_entry_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 20, fg_warehouse))

		# validate rm_item_2 quantity is clamped correctly (per-unit BOM = 1 → max 20)
		for row in stock_entry_2.items:
			if row.item_code == rm_item_2:
				self.assertLessEqual(row.qty, 20)
				self.assertGreaterEqual(row.qty, 0)

	def test_overproduction_allowed_qty(self):
		"""Test overproduction allowed qty in work order"""
		allow_overproduction("overproduction_percentage_for_work_order", 50)

		wo_order = make_wo_order_test_record(planned_start_date=now(), qty=10)

		test_stock_entry.make_stock_entry(
			item_code="_Test Item", target="Stores - _TC", qty=100, basic_rate=100
		)
		test_stock_entry.make_stock_entry(
			item_code="_Test Item Home Desktop 100",
			target="_Test Warehouse - _TC",
			qty=100,
			basic_rate=1000.0,
		)

		mt_stock_entry = frappe.get_doc(
			make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 10)
		)
		mt_stock_entry.submit()

		fg_stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
		fg_stock_entry.items[2].qty = 15
		fg_stock_entry.fg_completed_qty = 15
		fg_stock_entry.submit()

		wo_order.reload()

		self.assertEqual(wo_order.produced_qty, 15)
		self.assertEqual(wo_order.status, "Completed")

		allow_overproduction("overproduction_percentage_for_work_order", 0)

	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 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 test_non_stock_items_shown_in_work_order(self):
		"""Non stock, non phantom raw materials should appear in non_stock_items with scaled qty & amount."""
		fg_item = make_item("_Test WO Non Stock FG", {"is_stock_item": 1}).name
		stock_rm = make_item(
			"_Test WO Non Stock - Stock RM", {"is_stock_item": 1, "valuation_rate": 100}
		).name
		non_stock_rm = make_item(
			"_Test WO Non Stock - Non Stock RM", {"is_stock_item": 0, "valuation_rate": 7}
		).name

		bom = frappe.get_doc(
			{
				"doctype": "BOM",
				"item": fg_item,
				"currency": "INR",
				"quantity": 8,
				"company": "_Test Company",
			}
		)
		bom.append("items", {"item_code": stock_rm, "qty": 5})
		bom.append("items", {"item_code": non_stock_rm, "qty": 3})
		bom.insert()
		bom.submit()

		wo_order = make_wo_order_test_record(
			production_item=fg_item, bom_no=bom.name, qty=20, skip_transfer=1, do_not_save=True
		)

		non_stock_items = wo_order.non_stock_items
		# only the non stock, non phantom item is shown; the stock item is excluded
		self.assertEqual(len(non_stock_items), 1)
		row = non_stock_items[0]
		self.assertEqual(row.item_code, non_stock_rm)
		# qty = (bom_item_qty / bom_qty) * wo_qty = (3 / 8) * 20 = 7.5
		self.assertEqual(flt(row.qty, 6), 7.5)
		# amount = base_rate * qty = 7 * 7.5 = 52.5
		self.assertEqual(flt(row.amount, 6), 52.5)

	def test_secondary_items_from_bom_without_manufacture_entry(self):
		"""Without any manufacture entry, secondary items are derived from the BOM with scaled qty & amount."""
		fg_item = make_item("_Test WO Sec BOM FG", {"is_stock_item": 1}).name
		stock_rm = make_item("_Test WO Sec BOM RM", {"is_stock_item": 1, "valuation_rate": 100}).name
		scrap_item = make_item("_Test WO Sec BOM Scrap", {"is_stock_item": 1, "valuation_rate": 0}).name

		bom = frappe.get_doc(
			{
				"doctype": "BOM",
				"item": fg_item,
				"currency": "INR",
				"quantity": 8,
				"company": "_Test Company",
			}
		)
		bom.append("items", {"item_code": stock_rm, "qty": 2})
		bom.append(
			"secondary_items",
			{
				"type": "Scrap",
				"item_code": scrap_item,
				"item_name": scrap_item,
				"qty": 3,
				"cost_allocation_per": 25,
				"process_loss_per": 0,
			},
		)
		bom.insert()
		bom.submit()
		# cost = raw_material_cost * (cost_allocation_per / 100) = 200 * 0.25 = 50
		self.assertEqual(flt(bom.secondary_items[0].cost, 6), 50.0)

		wo_order = make_wo_order_test_record(
			production_item=fg_item, bom_no=bom.name, qty=20, skip_transfer=1
		)

		secondary_items = wo_order.secondary_items
		self.assertEqual(len(secondary_items), 1)
		row = secondary_items[0]
		self.assertEqual(row.item_code, scrap_item)
		self.assertEqual(row.type, "Scrap")
		# data is fetched from the BOM (carries bom_qty)
		self.assertEqual(flt(row.bom_qty), 8.0)
		# qty = (bom_secondary_qty / bom_qty) * wo_qty = (3 / 8) * 20 = 7.5
		self.assertEqual(flt(row.qty, 6), 7.5)
		# amount = cost * qty = 50 * 7.5 = 375
		self.assertEqual(flt(row.amount, 6), 375.0)

	def test_secondary_items_reflect_manufacture_entry(self):
		"""Once a manufacture entry exists, secondary items reflect what was generated, not the BOM."""
		fg_item = make_item("_Test WO Sec SE FG", {"is_stock_item": 1}).name
		stock_rm = make_item("_Test WO Sec SE RM", {"is_stock_item": 1, "valuation_rate": 100}).name
		scrap_item = make_item("_Test WO Sec SE Scrap", {"is_stock_item": 1, "valuation_rate": 0}).name

		bom = frappe.get_doc(
			{
				"doctype": "BOM",
				"item": fg_item,
				"currency": "INR",
				"quantity": 8,
				"company": "_Test Company",
			}
		)
		bom.append("items", {"item_code": stock_rm, "qty": 2})
		bom.append(
			"secondary_items",
			{
				"type": "Scrap",
				"item_code": scrap_item,
				"item_name": scrap_item,
				"qty": 3,
				"cost_allocation_per": 25,
				"process_loss_per": 0,
			},
		)
		bom.insert()
		bom.submit()

		wo_order = make_wo_order_test_record(
			production_item=fg_item,
			bom_no=bom.name,
			qty=20,
			skip_transfer=1,
			source_warehouse="_Test Warehouse - _TC",
		)

		# before any manufacture entry, data comes from the BOM
		self.assertEqual(flt(wo_order.secondary_items[0].qty, 6), 7.5)

		# make raw material available and manufacture a partial quantity
		test_stock_entry.make_stock_entry(
			item_code=stock_rm, target="_Test Warehouse - _TC", qty=100, basic_rate=100
		)
		manufacture_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 8))
		manufacture_entry.submit()

		generated_row = next(row for row in manufacture_entry.items if row.type == "Scrap")

		wo_order.reload()
		secondary_items = wo_order.secondary_items
		self.assertEqual(len(secondary_items), 1)
		row = secondary_items[0]
		# now sourced from the manufacture entry, not the BOM
		self.assertIsNone(row.get("bom_qty"))
		self.assertEqual(row.item_code, scrap_item)
		self.assertEqual(flt(row.qty, 6), flt(generated_row.qty, 6))
		self.assertEqual(flt(row.amount, 6), flt(generated_row.amount, 6))
		# 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)

	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()

		# 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)


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


def make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse):
	from erpnext.stock.doctype.stock_entry.test_stock_entry import (
		make_stock_entry as make_stock_entry_test_record,
	)

	batches = []
	for qty, rate in ((5, 100), (5, 200)):
		stock_entry = make_stock_entry_test_record(
			item_code=rm_item,
			target=source_warehouse,
			qty=qty,
			basic_rate=rate,
		)
		stock_entry.submit()
		stock_entry.reload()

		batch_no = get_batch_from_bundle(stock_entry.items[0].serial_and_batch_bundle)
		batch_doc = frappe.get_doc("Batch", batch_no)

		# keep early expiry date for the batch having rate 200
		days = 10 if rate == 100 else 1
		batch_doc.db_set("expiry_date", add_to_date(now(), days=days))

		batches.append(batch_no)

		stock_entry = make_stock_entry_test_record(
			item_code=rm_item,
			target=wip_warehouse,
			qty=qty,
			basic_rate=rate,
		)
		stock_entry.submit()
		stock_entry.reload()
		batch_no = get_batch_from_bundle(stock_entry.items[0].serial_and_batch_bundle)
		batch_doc = frappe.get_doc("Batch", batch_no)
		batch_doc.db_set("expiry_date", add_to_date(now(), days=10))

	return batches


def make_operation(**kwargs):
	kwargs = frappe._dict(kwargs)

	operation_doc = frappe.get_doc(
		{
			"doctype": "Operation",
			"name": kwargs.operation,
			"workstation": kwargs.workstation,
		}
	)
	operation_doc.insert()

	return operation_doc


def make_workstation(**kwargs):
	kwargs = frappe._dict(kwargs)

	workstation_doc = frappe.get_doc(
		{
			"doctype": "Workstation",
			"workstation_name": kwargs.workstation,
			"workstation_type": kwargs.workstation_type,
			"production_capacity": kwargs.production_capacity or 0,
			"hour_rate": kwargs.hour_rate or 100,
		}
	)
	workstation_doc.insert()

	return workstation_doc


def prepare_boms_for_sub_assembly_test():
	if not frappe.db.exists("BOM", {"item": "Test Final SF Item 1"}):
		bom = make_bom(
			item="Test Final SF Item 1",
			source_warehouse="Stores - _TC",
			raw_materials=["Test Final RM Item 1"],
			operating_cost_per_bom_quantity=100,
			do_not_submit=True,
		)

		bom.append("secondary_items", {"item_code": "Test Final Scrap Item 1", "qty": 1, "is_legacy": 1})

		bom.submit()

	if not frappe.db.exists("BOM", {"item": "Test Final SF Item 2"}):
		bom = make_bom(
			item="Test Final SF Item 2",
			source_warehouse="Stores - _TC",
			raw_materials=["Test Final RM Item 2"],
			operating_cost_per_bom_quantity=200,
			do_not_submit=True,
		)

		bom.append("secondary_items", {"item_code": "Test Final Scrap Item 2", "qty": 1, "is_legacy": 1})

		bom.submit()

	if not frappe.db.exists("BOM", {"item": "Test Final FG Item"}):
		bom = make_bom(
			item="Test Final FG Item",
			source_warehouse="Stores - _TC",
			raw_materials=["Test Final SF Item 1", "Test Final SF Item 2"],
		)


def prepare_data_for_workstation_type_check():
	from erpnext.manufacturing.doctype.operation.test_operation import make_operation
	from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
	from erpnext.manufacturing.doctype.workstation_type.test_workstation_type import (
		create_workstation_type,
	)

	workstation_types = ["Workstation Type 1", "Workstation Type 2", "Workstation Type 3"]
	for workstation_type in workstation_types:
		create_workstation_type(workstation_type=workstation_type)

	operations = ["Cutting", "Sewing", "Packing"]
	for operation in operations:
		make_operation(
			{
				"operation": operation,
			}
		)

	workstations = [
		{
			"workstation": "Workstation 1",
			"workstation_type": "Workstation Type 1",
		},
		{
			"workstation": "Workstation 2",
			"workstation_type": "Workstation Type 1",
		},
		{
			"workstation": "Workstation 3",
			"workstation_type": "Workstation Type 2",
		},
		{
			"workstation": "Workstation 4",
			"workstation_type": "Workstation Type 2",
		},
		{
			"workstation": "Workstation 5",
			"workstation_type": "Workstation Type 3",
		},
		{
			"workstation": "Workstation 6",
			"workstation_type": "Workstation Type 3",
		},
	]

	for row in workstations:
		make_workstation(row)

	fg_item = make_item(
		"Test FG Item For Workstation Type",
		{
			"is_stock_item": 1,
		},
	)

	rm_item = make_item(
		"Test RM Item For Workstation Type",
		{
			"is_stock_item": 1,
		},
	)

	if not frappe.db.exists("BOM", {"item": fg_item.name}):
		bom_doc = make_bom(
			item=fg_item.name,
			source_warehouse="Stores - _TC",
			raw_materials=[rm_item.name],
			do_not_submit=True,
		)

		submit_bom = False
		for index, operation in enumerate(operations):
			if not frappe.db.exists("BOM Operation", {"parent": bom_doc.name, "operation": operation}):
				bom_doc.append(
					"operations",
					{
						"operation": operation,
						"time_in_mins": 30,
						"hour_rate": 100,
						"workstation_type": workstation_types[index],
					},
				)

				submit_bom = True

		if submit_bom:
			bom_doc.submit()


def prepare_data_for_backflush_based_on_materials_transferred():
	batch_item_doc = make_item(
		"Test Batch MCC Keyboard",
		{
			"is_stock_item": 1,
			"has_batch_no": 1,
			"create_new_batch": 1,
			"batch_number_series": "TBMK.#####",
			"valuation_rate": 100,
			"stock_uom": "Nos",
		},
	)

	item = make_item(
		"Test FG Item with Batch Raw Materials",
		{
			"is_stock_item": 1,
		},
	)

	make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[batch_item_doc.name])

	# Make additional items not attached to a BOM
	make_item(
		"Test Batch Battery Consumable",
		{
			"is_stock_item": 1,
			"has_batch_no": 1,
			"create_new_batch": 1,
			"batch_number_series": "TBMK.#####",
			"valuation_rate": 2.33,
			"stock_uom": "Nos",
		},
	)
	make_item(
		"Test Serial Battery Consumable",
		{
			"is_stock_item": 1,
			"has_serial_no": 1,
			"serial_no_series": "TSBH.#####",
			"valuation_rate": 3.33,
			"stock_uom": "Nos",
		},
	)

	sn_item_doc = make_item(
		"Test Serial No BTT Headphone",
		{
			"is_stock_item": 1,
			"has_serial_no": 1,
			"serial_no_series": "TSBH.#####",
			"valuation_rate": 100,
			"stock_uom": "Nos",
		},
	)

	item = make_item(
		"Test FG Item with Serial No Raw Materials",
		{
			"is_stock_item": 1,
		},
	)

	make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[sn_item_doc.name])

	sn_batch_item_doc = make_item(
		"Test Batch Serial No WebCam",
		{
			"is_stock_item": 1,
			"has_batch_no": 1,
			"create_new_batch": 1,
			"batch_number_series": "TBSW.#####",
			"has_serial_no": 1,
			"serial_no_series": "TBSWC.#####",
			"valuation_rate": 100,
			"stock_uom": "Nos",
		},
	)

	item = make_item(
		"Test FG Item with Serial & Batch No Raw Materials",
		{
			"is_stock_item": 1,
		},
	)

	make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[sn_batch_item_doc.name])


def update_job_card(job_card, jc_qty=None, days=None):
	employee = frappe.db.get_value("Employee", {"status": "Active"}, "name")
	job_card_doc = frappe.get_doc("Job Card", job_card)
	job_card_doc.set(
		"secondary_items",
		[
			{"item_code": "Test RM Item 1 for Scrap Item Test", "stock_qty": 2},
			{"item_code": "Test RM Item 2 for Scrap Item Test", "stock_qty": 2},
		],
	)

	if jc_qty:
		job_card_doc.for_quantity = jc_qty

	for row in job_card_doc.scheduled_time_logs:
		job_card_doc.append(
			"time_logs",
			{
				"from_time": row.from_time,
				"to_time": row.to_time,
				"employee": employee,
				"time_in_mins": 60,
				"completed_qty": 0.0,
			},
		)

	if not job_card_doc.time_logs and days:
		planned_start_time = add_days(now(), days=days)
		job_card_doc.append(
			"time_logs",
			{
				"from_time": planned_start_time,
				"to_time": add_to_date(planned_start_time, minutes=60),
				"employee": employee,
				"time_in_mins": 60,
				"completed_qty": 0.0,
			},
		)

	job_card_doc.time_logs[0].completed_qty = job_card_doc.for_quantity

	job_card_doc.submit()


def get_secondary_item_details(bom_no):
	secondary_items = {}
	for item in frappe.db.sql(
		"""select item_code, stock_qty from `tabBOM Secondary Item`
		where parent = %s""",
		bom_no,
		as_dict=1,
	):
		secondary_items[item.item_code] = item.stock_qty

	return secondary_items


def allow_overproduction(fieldname, percentage):
	doc = frappe.get_doc("Manufacturing Settings")
	doc.update({fieldname: percentage})
	doc.save()


def make_wo_order_test_record(**args):
	args = frappe._dict(args)
	if args.company and args.company != "_Test Company":
		warehouse_map = {"fg_warehouse": "_Test FG Warehouse", "wip_warehouse": "_Test WIP Warehouse"}

		for attr, wh_name in warehouse_map.items():
			if not args.get(attr):
				args[attr] = create_warehouse(wh_name, company=args.company)

	wo_order = frappe.new_doc("Work Order")
	wo_order.production_item = args.production_item or args.item or args.item_code or "_Test FG Item"
	wo_order.bom_no = args.bom_no or frappe.db.get_value(
		"BOM", {"item": wo_order.production_item, "is_active": 1, "is_default": 1}
	)
	wo_order.qty = args.qty or 10
	wo_order.reserve_stock = args.reserve_stock or 0
	wo_order.wip_warehouse = args.wip_warehouse or "_Test Warehouse - _TC"
	wo_order.fg_warehouse = args.fg_warehouse or "_Test Warehouse 1 - _TC"
	wo_order.scrap_warehouse = args.fg_warehouse or "_Test Scrap Warehouse - _TC"
	wo_order.company = args.company or "_Test Company"
	wo_order.stock_uom = args.stock_uom or "_Test UOM"
	wo_order.use_multi_level_bom = args.use_multi_level_bom or 0
	wo_order.skip_transfer = args.skip_transfer or 0
	wo_order.get_items_and_operations_from_bom()
	wo_order.sales_order = args.sales_order or None
	wo_order.planned_start_date = args.planned_start_date or now()
	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
		for item in wo_order.get("required_items"):
			item.source_warehouse = args.source_warehouse

	if not args.do_not_save:
		wo_order.insert()

		if not args.do_not_submit:
			wo_order.submit()
	return wo_order
