fix: Pick List empty table and Serial-Batch items handling (#22426)

* chore: Pick List empty table and serial-batch items handling

* fix: Remove console statement

* chore: Added tests for batched and batched-serialised item
diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js
index 3a5ef76..ee218f2 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.js
+++ b/erpnext/stock/doctype/pick_list/pick_list.js
@@ -3,6 +3,9 @@
 
 frappe.ui.form.on('Pick List', {
 	setup: (frm) => {
+		frm.set_indicator_formatter('item_code',
+			function(doc) { return (doc.stock_qty === 0) ? "red" : "green"; });
+
 		frm.custom_make_buttons = {
 			'Delivery Note': 'Delivery Note',
 			'Stock Entry': 'Stock Entry',
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index 4b8b594..0da57b7 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -26,11 +26,12 @@
 				continue
 			if not item.serial_no:
 				frappe.throw(_("Row #{0}: {1} does not have any available serial numbers in {2}".format(
-					frappe.bold(item.idx), frappe.bold(item.item_code), frappe.bold(item.warehouse))))
+					frappe.bold(item.idx), frappe.bold(item.item_code), frappe.bold(item.warehouse))),
+					title=_("Serial Nos Required"))
 			if len(item.serial_no.split('\n')) == item.picked_qty:
 				continue
 			frappe.throw(_('For item {0} at row {1}, count of serial numbers does not match with the picked quantity')
-				.format(frappe.bold(item.item_code), frappe.bold(item.idx)))
+				.format(frappe.bold(item.item_code), frappe.bold(item.idx)), title=_("Quantity Mismatch"))
 
 	def set_item_locations(self, save=False):
 		items = self.aggregate_item_qty()
@@ -40,6 +41,9 @@
 		if self.parent_warehouse:
 			from_warehouses = frappe.db.get_descendants('Warehouse', self.parent_warehouse)
 
+		# Create replica before resetting, to handle empty table on update after submit.
+		locations_replica  = self.get('locations')
+
 		# reset
 		self.delete_key('locations')
 		for item_doc in items:
@@ -48,7 +52,7 @@
 			self.item_location_map.setdefault(item_code,
 				get_available_item_locations(item_code, from_warehouses, self.item_count_map.get(item_code), self.company))
 
-			locations = get_items_with_location_and_quantity(item_doc, self.item_location_map)
+			locations = get_items_with_location_and_quantity(item_doc, self.item_location_map, self.docstatus)
 
 			item_doc.idx = None
 			item_doc.name = None
@@ -62,6 +66,16 @@
 				location.update(row)
 				self.append('locations', location)
 
+		# If table is empty on update after submit, set stock_qty, picked_qty to 0 so that indicator is red
+		# and give feedback to the user. This is to avoid empty Pick Lists.
+		if not self.get('locations') and self.docstatus == 1:
+			for location in locations_replica:
+				location.stock_qty = 0
+				location.picked_qty = 0
+				self.append('locations', location)
+			frappe.msgprint(_("Please Restock Items and Update the Pick List to continue. To discontinue, cancel the Pick List."),
+				 title=_("Out of Stock"), indicator="red")
+
 		if save:
 			self.save()
 
@@ -97,11 +111,13 @@
 	if not pick_list.locations:
 		frappe.throw(_("Add items in the Item Locations table"))
 
-def get_items_with_location_and_quantity(item_doc, item_location_map):
+def get_items_with_location_and_quantity(item_doc, item_location_map, docstatus):
 	available_locations = item_location_map.get(item_doc.item_code)
 	locations = []
 
-	remaining_stock_qty = item_doc.stock_qty
+	# if stock qty is zero on submitted entry, show positive remaining qty to recalculate in case of restock.
+	remaining_stock_qty = item_doc.qty if (docstatus == 1 and item_doc.stock_qty == 0) else item_doc.stock_qty
+
 	while remaining_stock_qty > 0 and available_locations:
 		item_location = available_locations.pop(0)
 		item_location = frappe._dict(item_location)
@@ -119,13 +135,11 @@
 		if item_location.serial_no:
 			serial_nos = '\n'.join(item_location.serial_no[0: cint(stock_qty)])
 
-		auto_set_serial_no = frappe.db.get_single_value("Stock Settings", "automatically_set_serial_nos_based_on_fifo")
-
 		locations.append(frappe._dict({
 			'qty': qty,
 			'stock_qty': stock_qty,
 			'warehouse': item_location.warehouse,
-			'serial_no': serial_nos if auto_set_serial_no else item_doc.serial_no,
+			'serial_no': serial_nos,
 			'batch_no': item_location.batch_no
 		}))
 
@@ -137,7 +151,7 @@
 			item_location.qty = qty_diff
 			if item_location.serial_no:
 				# set remaining serial numbers
-				item_location.serial_no = item_location.serial_no[-qty_diff:]
+				item_location.serial_no = item_location.serial_no[-int(qty_diff):]
 			available_locations = [item_location] + available_locations
 
 	# update available locations for the item
@@ -146,9 +160,14 @@
 
 def get_available_item_locations(item_code, from_warehouses, required_qty, company, ignore_validation=False):
 	locations = []
-	if frappe.get_cached_value('Item', item_code, 'has_serial_no'):
+	has_serial_no  = frappe.get_cached_value('Item', item_code, 'has_serial_no')
+	has_batch_no = frappe.get_cached_value('Item', item_code, 'has_batch_no')
+
+	if has_batch_no and has_serial_no:
+		locations = get_available_item_locations_for_serial_and_batched_item(item_code, from_warehouses, required_qty, company)
+	elif has_serial_no:
 		locations = get_available_item_locations_for_serialized_item(item_code, from_warehouses, required_qty, company)
-	elif frappe.get_cached_value('Item', item_code, 'has_batch_no'):
+	elif has_batch_no:
 		locations = get_available_item_locations_for_batched_item(item_code, from_warehouses, required_qty, company)
 	else:
 		locations = get_available_item_locations_for_other_item(item_code, from_warehouses, required_qty, company)
@@ -158,8 +177,9 @@
 	remaining_qty = required_qty - total_qty_available
 
 	if remaining_qty > 0 and not ignore_validation:
-		frappe.msgprint(_('{0} units of {1} is not available.')
-			.format(remaining_qty, frappe.get_desk_link('Item', item_code)))
+		frappe.msgprint(_('{0} units of Item {1} is not available.')
+			.format(remaining_qty, frappe.get_desk_link('Item', item_code)),
+			title=_("Insufficient Stock"))
 
 	return locations
 
@@ -226,6 +246,34 @@
 
 	return batch_locations
 
+def get_available_item_locations_for_serial_and_batched_item(item_code, from_warehouses, required_qty, company):
+	# Get batch nos by FIFO
+	locations = get_available_item_locations_for_batched_item(item_code, from_warehouses, required_qty, company)
+
+	filters = frappe._dict({
+		'item_code': item_code,
+		'company': company,
+		'warehouse': ['!=', ''],
+		'batch_no': ''
+	})
+
+	# Get Serial Nos by FIFO for Batch No
+	for location in locations:
+		filters.batch_no = location.batch_no
+		filters.warehouse = location.warehouse
+		location.qty = required_qty if location.qty > required_qty else location.qty # if extra qty in batch
+
+		serial_nos = frappe.get_list('Serial No',
+			fields=['name'],
+			filters=filters,
+			limit=location.qty,
+			order_by='purchase_date')
+
+		serial_nos = [sn.name for sn in serial_nos]
+		location.serial_no = serial_nos
+
+	return locations
+
 def get_available_item_locations_for_other_item(item_code, from_warehouses, required_qty, company):
 	# gets all items available in different warehouses
 	warehouses = [x.get('name') for x in frappe.get_list("Warehouse", {'company': company}, "name")]
diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py
index 1b9ff41..8ea7f89d 100644
--- a/erpnext/stock/doctype/pick_list/test_pick_list.py
+++ b/erpnext/stock/doctype/pick_list/test_pick_list.py
@@ -7,6 +7,8 @@
 import unittest
 test_dependencies = ['Item', 'Sales Invoice', 'Stock Entry', 'Batch']
 
+from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+from erpnext.stock.doctype.item.test_item import create_item
 from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation \
 		import EmptyStockReconciliationItemsError
 
@@ -49,7 +51,7 @@
 		self.assertEqual(pick_list.locations[0].warehouse, '_Test Warehouse - _TC')
 		self.assertEqual(pick_list.locations[0].qty, 5)
 
-	def test_pick_list_splits_row_according_to_warhouse_availability(self):
+	def test_pick_list_splits_row_according_to_warehouse_availability(self):
 		try:
 			frappe.get_doc({
 				'doctype': 'Stock Reconciliation',
@@ -122,7 +124,10 @@
 			}]
 		})
 
-		stock_reconciliation.submit()
+		try:
+			stock_reconciliation.submit()
+		except EmptyStockReconciliationItemsError:
+			pass
 
 		pick_list = frappe.get_doc({
 			'doctype': 'Pick List',
@@ -145,6 +150,85 @@
 		self.assertEqual(pick_list.locations[0].qty, 5)
 		self.assertEqual(pick_list.locations[0].serial_no, '123450\n123451\n123452\n123453\n123454')
 
+	def test_pick_list_shows_batch_no_for_batched_item(self):
+		# check if oldest batch no is picked
+		item = frappe.db.exists("Item", {'item_name': 'Batched Item'})
+		if not item:
+			item = create_item("Batched Item")
+			item.has_batch_no = 1
+			item.create_new_batch = 1
+			item.batch_number_series = "B-BATCH-.##"
+			item.save()
+		else:
+			item = frappe.get_doc("Item", {'item_name': 'Batched Item'})
+
+		pr1 = make_purchase_receipt(item_code="Batched Item", qty=1, rate=100.0)
+
+		pr1.load_from_db()
+		oldest_batch_no = pr1.items[0].batch_no
+
+		pr2 = make_purchase_receipt(item_code="Batched Item", qty=2, rate=100.0)
+
+		pick_list = frappe.get_doc({
+			'doctype': 'Pick List',
+			'company': '_Test Company',
+			'purpose': 'Material Transfer',
+			'locations': [{
+				'item_code': 'Batched Item',
+				'qty': 1,
+				'stock_qty': 1,
+				'conversion_factor': 1,
+			}]
+		})
+		pick_list.set_item_locations()
+
+		self.assertEqual(pick_list.locations[0].batch_no, oldest_batch_no)
+
+		pr1.cancel()
+		pr2.cancel()
+
+
+	def test_pick_list_for_batched_and_serialised_item(self):
+		# check if oldest batch no and serial nos are picked
+		item = frappe.db.exists("Item", {'item_name': 'Batched and Serialised Item'})
+		if not item:
+			item = create_item("Batched and Serialised Item")
+			item.has_batch_no = 1
+			item.create_new_batch = 1
+			item.has_serial_no = 1
+			item.batch_number_series = "B-BATCH-.##"
+			item.serial_no_series = "S-.####"
+			item.save()
+		else:
+			item = frappe.get_doc("Item", {'item_name': 'Batched and Serialised Item'})
+
+		pr1 = make_purchase_receipt(item_code="Batched and Serialised Item", qty=2, rate=100.0)
+
+		pr1.load_from_db()
+		oldest_batch_no = pr1.items[0].batch_no
+		oldest_serial_nos = pr1.items[0].serial_no
+
+		pr2 = make_purchase_receipt(item_code="Batched and Serialised Item", qty=2, rate=100.0)
+
+		pick_list = frappe.get_doc({
+			'doctype': 'Pick List',
+			'company': '_Test Company',
+			'purpose': 'Material Transfer',
+			'locations': [{
+				'item_code': 'Batched and Serialised Item',
+				'qty': 2,
+				'stock_qty': 2,
+				'conversion_factor': 1,
+			}]
+		})
+		pick_list.set_item_locations()
+
+		self.assertEqual(pick_list.locations[0].batch_no, oldest_batch_no)
+		self.assertEqual(pick_list.locations[0].serial_no, oldest_serial_nos)
+
+		pr1.cancel()
+		pr2.cancel()
+
 	def test_pick_list_for_items_from_multiple_sales_orders(self):
 		try:
 			frappe.get_doc({
diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json
index 71fbf9a..8665986 100644
--- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json
+++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json
@@ -180,7 +180,7 @@
  ],
  "istable": 1,
  "links": [],
- "modified": "2020-03-13 19:08:21.995986",
+ "modified": "2020-06-24 17:18:57.357120",
  "modified_by": "Administrator",
  "module": "Stock",
  "name": "Pick List Item",