Merge branch 'develop' into taxjar-nexus-display-condition
diff --git a/erpnext/accounts/doctype/fiscal_year/test_fiscal_year.py b/erpnext/accounts/doctype/fiscal_year/test_fiscal_year.py
index bc8c6ab..69e13a4 100644
--- a/erpnext/accounts/doctype/fiscal_year/test_fiscal_year.py
+++ b/erpnext/accounts/doctype/fiscal_year/test_fiscal_year.py
@@ -5,10 +5,10 @@
 import unittest
 
 import frappe
+from frappe.utils import now_datetime
 
 from erpnext.accounts.doctype.fiscal_year.fiscal_year import FiscalYearIncorrectDate
 
-test_records = frappe.get_test_records('Fiscal Year')
 test_ignore = ["Company"]
 
 class TestFiscalYear(unittest.TestCase):
@@ -25,3 +25,29 @@
 		})
 
 		self.assertRaises(FiscalYearIncorrectDate, fy.insert)
+
+
+def test_record_generator():
+	test_records = [
+			{
+				"doctype": "Fiscal Year",
+				"year": "_Test Short Fiscal Year 2011",
+				"is_short_year": 1,
+				"year_end_date": "2011-04-01",
+				"year_start_date": "2011-12-31"
+			 }
+	]
+
+	start = 2012
+	end = now_datetime().year + 5
+	for year in range(start, end):
+		test_records.append({
+			"doctype": "Fiscal Year",
+			"year": f"_Test Fiscal Year {year}",
+			"year_start_date": f"{year}-01-01",
+			"year_end_date": f"{year}-12-31"
+		})
+
+	return test_records
+
+test_records = test_record_generator()
diff --git a/erpnext/accounts/doctype/fiscal_year/test_records.json b/erpnext/accounts/doctype/fiscal_year/test_records.json
deleted file mode 100644
index 4405253..0000000
--- a/erpnext/accounts/doctype/fiscal_year/test_records.json
+++ /dev/null
@@ -1,69 +0,0 @@
-[
- {
-  "doctype": "Fiscal Year",
-  "year": "_Test Short Fiscal Year 2011",
-  "is_short_year": 1,
-  "year_end_date": "2011-04-01",
-  "year_start_date": "2011-12-31"
- },
- {
-  "doctype": "Fiscal Year",
-  "year": "_Test Fiscal Year 2012",
-  "year_end_date": "2012-12-31",
-  "year_start_date": "2012-01-01"
- },
- {
-  "doctype": "Fiscal Year",
-  "year": "_Test Fiscal Year 2013",
-  "year_end_date": "2013-12-31",
-  "year_start_date": "2013-01-01"
- },
- {
-  "doctype": "Fiscal Year",
-  "year": "_Test Fiscal Year 2014",
-  "year_end_date": "2014-12-31",
-  "year_start_date": "2014-01-01"
- },
- {
-  "doctype": "Fiscal Year",
-  "year": "_Test Fiscal Year 2015",
-  "year_end_date": "2015-12-31",
-  "year_start_date": "2015-01-01"
- },
- {
-  "doctype": "Fiscal Year",
-  "year": "_Test Fiscal Year 2016",
-  "year_end_date": "2016-12-31",
-  "year_start_date": "2016-01-01"
- },
- {
-  "doctype": "Fiscal Year",
-  "year": "_Test Fiscal Year 2017",
-  "year_end_date": "2017-12-31",
-  "year_start_date": "2017-01-01"
- },
- {
-  "doctype": "Fiscal Year",
-  "year": "_Test Fiscal Year 2018",
-  "year_end_date": "2018-12-31",
-  "year_start_date": "2018-01-01"
- },
- {
-  "doctype": "Fiscal Year",
-  "year": "_Test Fiscal Year 2019",
-  "year_end_date": "2019-12-31",
-  "year_start_date": "2019-01-01"
- },
- {
-  "doctype": "Fiscal Year",
-  "year": "_Test Fiscal Year 2020",
-  "year_end_date": "2020-12-31",
-  "year_start_date": "2020-01-01"
- },
- {
-  "doctype": "Fiscal Year",
-  "year": "_Test Fiscal Year 2021",
-  "year_end_date": "2021-12-31",
-  "year_start_date": "2021-01-01"
- }
-]
diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js
index da5778e..c2b1bbc 100644
--- a/erpnext/assets/doctype/asset/asset.js
+++ b/erpnext/assets/doctype/asset/asset.js
@@ -80,20 +80,20 @@
 
 		if (frm.doc.docstatus==1) {
 			if (in_list(["Submitted", "Partially Depreciated", "Fully Depreciated"], frm.doc.status)) {
-				frm.add_custom_button("Transfer Asset", function() {
+				frm.add_custom_button(__("Transfer Asset"), function() {
 					erpnext.asset.transfer_asset(frm);
 				}, __("Manage"));
 
-				frm.add_custom_button("Scrap Asset", function() {
+				frm.add_custom_button(__("Scrap Asset"), function() {
 					erpnext.asset.scrap_asset(frm);
 				}, __("Manage"));
 
-				frm.add_custom_button("Sell Asset", function() {
+				frm.add_custom_button(__("Sell Asset"), function() {
 					frm.trigger("make_sales_invoice");
 				}, __("Manage"));
 
 			} else if (frm.doc.status=='Scrapped') {
-				frm.add_custom_button("Restore Asset", function() {
+				frm.add_custom_button(__("Restore Asset"), function() {
 					erpnext.asset.restore_asset(frm);
 				}, __("Manage"));
 			}
@@ -121,7 +121,7 @@
 			}
 
 			if (frm.doc.purchase_receipt || !frm.doc.is_existing_asset) {
-				frm.add_custom_button("View General Ledger", function() {
+				frm.add_custom_button(__("View General Ledger"), function() {
 					frappe.route_options = {
 						"voucher_no": frm.doc.name,
 						"from_date": frm.doc.available_for_use_date,
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index f4a88dc..f590d68 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -21,9 +21,10 @@
 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 ERPNextTestCase, timeout
 
 
-class TestWorkOrder(unittest.TestCase):
+class TestWorkOrder(ERPNextTestCase):
 	def setUp(self):
 		self.warehouse = '_Test Warehouse 2 - _TC'
 		self.item = '_Test Item'
@@ -376,6 +377,7 @@
 		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', {
@@ -769,6 +771,7 @@
 			total_pl_qty
 		)
 
+	@timeout(seconds=60)
 	def test_job_card_scrap_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']
diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py
index 769e066..aa83726 100644
--- a/erpnext/selling/doctype/quotation/test_quotation.py
+++ b/erpnext/selling/doctype/quotation/test_quotation.py
@@ -302,6 +302,109 @@
 
 		enable_calculate_bundle_price(enable=0)
 
+	def test_product_bundle_price_calculation_for_multiple_product_bundles_when_calculate_bundle_price_is_checked(self):
+		from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
+		from erpnext.stock.doctype.item.test_item import make_item
+
+		make_item("_Test Product Bundle 1", {"is_stock_item": 0})
+		make_item("_Test Product Bundle 2", {"is_stock_item": 0})
+		make_item("_Test Bundle Item 1", {"is_stock_item": 1})
+		make_item("_Test Bundle Item 2", {"is_stock_item": 1})
+		make_item("_Test Bundle Item 3", {"is_stock_item": 1})
+
+		make_product_bundle("_Test Product Bundle 1",
+			["_Test Bundle Item 1", "_Test Bundle Item 2"])
+		make_product_bundle("_Test Product Bundle 2",
+			["_Test Bundle Item 2", "_Test Bundle Item 3"])
+
+		enable_calculate_bundle_price()
+
+		item_list = [
+			{
+				"item_code": "_Test Product Bundle 1",
+				"warehouse": "",
+				"qty": 1,
+				"rate": 400,
+				"delivered_by_supplier": 1,
+				"supplier": '_Test Supplier'
+			},
+			{
+				"item_code": "_Test Product Bundle 2",
+				"warehouse": "",
+				"qty": 1,
+				"rate": 400,
+				"delivered_by_supplier": 1,
+				"supplier": '_Test Supplier'
+			}
+		]
+
+		quotation = make_quotation(item_list=item_list, do_not_submit=1)
+		quotation.packed_items[0].rate = 100
+		quotation.packed_items[1].rate = 200
+		quotation.packed_items[2].rate = 200
+		quotation.packed_items[3].rate = 300
+		quotation.save()
+
+		expected_values = [300, 500]
+
+		for item in quotation.items:
+			self.assertEqual(item.amount, expected_values[item.idx-1])
+
+		enable_calculate_bundle_price(enable=0)
+
+	def test_packed_items_indices_are_reset_when_product_bundle_is_deleted_from_items_table(self):
+		from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
+		from erpnext.stock.doctype.item.test_item import make_item
+
+		make_item("_Test Product Bundle 1", {"is_stock_item": 0})
+		make_item("_Test Product Bundle 2", {"is_stock_item": 0})
+		make_item("_Test Product Bundle 3", {"is_stock_item": 0})
+		make_item("_Test Bundle Item 1", {"is_stock_item": 1})
+		make_item("_Test Bundle Item 2", {"is_stock_item": 1})
+		make_item("_Test Bundle Item 3", {"is_stock_item": 1})
+
+		make_product_bundle("_Test Product Bundle 1",
+			["_Test Bundle Item 1", "_Test Bundle Item 2"])
+		make_product_bundle("_Test Product Bundle 2",
+			["_Test Bundle Item 2", "_Test Bundle Item 3"])
+		make_product_bundle("_Test Product Bundle 3",
+			["_Test Bundle Item 3", "_Test Bundle Item 1"])
+
+		item_list = [
+			{
+				"item_code": "_Test Product Bundle 1",
+				"warehouse": "",
+				"qty": 1,
+				"rate": 400,
+				"delivered_by_supplier": 1,
+				"supplier": '_Test Supplier'
+			},
+			{
+				"item_code": "_Test Product Bundle 2",
+				"warehouse": "",
+				"qty": 1,
+				"rate": 400,
+				"delivered_by_supplier": 1,
+				"supplier": '_Test Supplier'
+			},
+			{
+				"item_code": "_Test Product Bundle 3",
+				"warehouse": "",
+				"qty": 1,
+				"rate": 400,
+				"delivered_by_supplier": 1,
+				"supplier": '_Test Supplier'
+			}
+		]
+
+		quotation = make_quotation(item_list=item_list, do_not_submit=1)
+		del quotation.items[1]
+		quotation.save()
+
+		for id, item in enumerate(quotation.packed_items):
+			expected_index = id + 1
+			self.assertEqual(item.idx, expected_index)
+
 test_records = frappe.get_test_records('Quotation')
 
 def enable_calculate_bundle_price(enable=1):
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index 47b8ebd..1c24825 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -925,6 +925,7 @@
 				"supplier",
 				"pricing_rules"
 			],
+			"condition": lambda doc: doc.parent_item in items_to_map
 		}
 	}, target_doc, set_missing_values)
 
diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py
index 3f73093..e4091c4 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.py
+++ b/erpnext/stock/doctype/packed_item/packed_item.py
@@ -108,9 +108,32 @@
 
 	packed_items = doc.get("packed_items")
 	doc.set("packed_items", [])
+
 	for d in packed_items:
 		if d not in delete_list:
-			doc.append("packed_items", d)
+			add_item_to_packing_list(doc, d)
+
+def add_item_to_packing_list(doc, packed_item):
+	doc.append("packed_items", {
+		'parent_item': packed_item.parent_item,
+		'item_code': packed_item.item_code,
+		'item_name': packed_item.item_name,
+		'uom': packed_item.uom,
+		'qty': packed_item.qty,
+		'rate': packed_item.rate,
+		'conversion_factor': packed_item.conversion_factor,
+		'description': packed_item.description,
+		'warehouse': packed_item.warehouse,
+		'batch_no': packed_item.batch_no,
+		'actual_batch_qty': packed_item.actual_batch_qty,
+		'serial_no': packed_item.serial_no,
+		'target_warehouse': packed_item.target_warehouse,
+		'actual_qty': packed_item.actual_qty,
+		'projected_qty': packed_item.projected_qty,
+		'incoming_rate': packed_item.incoming_rate,
+		'prevdoc_doctype': packed_item.prevdoc_doctype,
+		'parent_detail_docname': packed_item.parent_detail_docname
+	})
 
 def update_product_bundle_price(doc, parent_items):
 	"""Updates the prices of Product Bundles based on the rates of the Items in the bundle."""
@@ -128,7 +151,8 @@
 		else:
 			update_parent_item_price(doc, parent_items[parent_items_index][0], bundle_price)
 
-			bundle_price = 0
+			bundle_item_rate = bundle_item.rate if bundle_item.rate else 0
+			bundle_price = bundle_item.qty * bundle_item_rate
 			parent_items_index += 1
 
 	# for the last product bundle
diff --git a/erpnext/tests/utils.py b/erpnext/tests/utils.py
index 91df548..fbf2594 100644
--- a/erpnext/tests/utils.py
+++ b/erpnext/tests/utils.py
@@ -2,6 +2,7 @@
 # License: GNU General Public License v3. See license.txt
 
 import copy
+import signal
 import unittest
 from contextlib import contextmanager
 from typing import Any, Dict, NewType, Optional
@@ -135,3 +136,23 @@
 			report_execute_fn(filter_with_optional_param)
 
 	return report_data
+
+
+def timeout(seconds=30, error_message="Test timed out."):
+	""" Timeout decorator to ensure a test doesn't run for too long.
+
+		adapted from https://stackoverflow.com/a/2282656"""
+	def decorator(func):
+		def _handle_timeout(signum, frame):
+			raise Exception(error_message)
+
+		def wrapper(*args, **kwargs):
+			signal.signal(signal.SIGALRM, _handle_timeout)
+			signal.alarm(seconds)
+			try:
+				result = func(*args, **kwargs)
+			finally:
+				signal.alarm(0)
+			return result
+		return wrapper
+	return decorator