refactor: Item picking logic

- Fix serial number selection
- Get limited item location based on required qty
- Store total item count to properly track available locations
- Pass missing serial no.
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index 2f276e2..21b9cc1 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -8,7 +8,7 @@
 from six import iteritems
 from frappe.model.document import Document
 from frappe import _
-from frappe.utils import floor, flt, today
+from frappe.utils import floor, flt, today, cint
 from frappe.model.mapper import get_mapped_doc, map_child_doc
 from erpnext.stock.get_item_details import get_conversion_factor
 from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note as create_delivery_note_from_sales_order
@@ -17,51 +17,69 @@
 
 class PickList(Document):
 	def set_item_locations(self):
-		item_locations = self.locations
+		items = self.aggregate_item_qty()
 		self.item_location_map = frappe._dict()
 
 		from_warehouses = None
 		if self.parent_warehouse:
 			from_warehouses = frappe.db.get_descendants('Warehouse', self.parent_warehouse)
 
-		# Reset
+		# reset
 		self.delete_key('locations')
-		for item_doc in item_locations:
+		for item_doc in items:
 			item_code = item_doc.item_code
-			if frappe.get_cached_value('Item', item_code, 'has_serial_no'):
-				locations = get_item_locations_based_on_serial_nos(item_doc)
-			elif frappe.get_cached_value('Item', item_code, 'has_batch_no'):
-				locations = get_item_locations_based_on_batch_nos(item_doc)
-			else:
-				if item_code not in self.item_location_map:
-					self.item_location_map[item_code] = get_available_items(item_code, from_warehouses)
-				locations = get_items_with_warehouse_and_quantity(item_doc, from_warehouses, self.item_location_map)
+
+			self.item_location_map.setdefault(item_code,
+				get_available_item_locations(item_code, from_warehouses, self.item_count_map.get(item_code)))
+
+			locations = get_items_with_location_and_quantity(item_doc, self.item_location_map)
 
 			item_doc.idx = None
 			item_doc.name = None
 
 			for row in locations:
-				stock_qty = row.get('qty', 0) * item_doc.conversion_factor
 				row.update({
-					'stock_qty': stock_qty,
-					'picked_qty': stock_qty
+					'picked_qty': row.stock_qty
 				})
 
 				location = item_doc.as_dict()
 				location.update(row)
 				self.append('locations', location)
 
-def get_items_with_warehouse_and_quantity(item_doc, from_warehouses, item_location_map):
+	def aggregate_item_qty(self):
+		locations = self.locations
+		self.item_count_map = {}
+		# aggregate qty for same item
+		item_map = frappe._dict()
+		for item in locations:
+			item_code = item.item_code
+			reference = item.sales_order_item or item.material_request_item
+			key = (item_code, item.uom, reference)
+
+			item.idx = None
+			item.name = None
+
+			if item_map.get(key):
+				item_map[key].qty += item.qty
+				item_map[key].stock_qty += item.stock_qty
+			else:
+				item_map[key] = item
+
+			# maintain count of each item (useful to limit get query)
+			self.item_count_map.setdefault(item_code, 0)
+			self.item_count_map[item_code] += item.stock_qty
+
+		return item_map.values()
+
+
+def get_items_with_location_and_quantity(item_doc, item_location_map):
 	available_locations = item_location_map.get(item_doc.item_code)
 	locations = []
-	skip_warehouse = None
-
-	if item_doc.material_request:
-		skip_warehouse = frappe.get_value('Material Request Item', item_doc.material_request_item, 'warehouse')
 
 	remaining_stock_qty = item_doc.stock_qty
 	while remaining_stock_qty > 0 and available_locations:
 		item_location = available_locations.pop(0)
+		item_location = frappe._dict(item_location)
 
 		stock_qty = remaining_stock_qty if item_location.qty >= remaining_stock_qty else item_location.qty
 		qty = stock_qty / (item_doc.conversion_factor or 1)
@@ -72,54 +90,61 @@
 			stock_qty = qty * item_doc.conversion_factor
 			if not stock_qty: break
 
-		locations.append({
+		serial_nos = None
+		if item_location.serial_no:
+			serial_nos = '\n'.join(item_location.serial_no[0: cint(stock_qty)])
+
+		locations.append(frappe._dict({
 			'qty': qty,
-			'warehouse': item_location.warehouse
-		})
+			'stock_qty': stock_qty,
+			'warehouse': item_location.warehouse,
+			'serial_no': serial_nos,
+			'batch_no': item_location.batch_no
+		}))
+
 		remaining_stock_qty -= stock_qty
 
 		qty_diff = item_location.qty - stock_qty
 		# if extra quantity is available push current warehouse to available locations
-		if qty_diff:
+		if qty_diff > 0:
 			item_location.qty = qty_diff
+			if item_location.serial_no:
+				# set remaining serial numbers
+				item_location.serial_no = item_location.serial_no[-qty_diff:]
 			available_locations = [item_location] + available_locations
 
-	if remaining_stock_qty:
-		frappe.msgprint('{0} {1} of {2} is not available.'
-			.format(remaining_stock_qty / item_doc.conversion_factor, item_doc.uom, item_doc.item_code))
-
 	# update available locations for the item
 	item_location_map[item_doc.item_code] = available_locations
 	return locations
 
-def get_available_items(item_code, from_warehouses):
-	# gets all items available in different warehouses
+def get_available_item_locations(item_code, from_warehouses, required_qty):
+	if frappe.get_cached_value('Item', item_code, 'has_serial_no'):
+		return get_available_item_locations_for_serialized_item(item_code, from_warehouses, required_qty)
+	elif frappe.get_cached_value('Item', item_code, 'has_batch_no'):
+		return get_available_item_locations_for_batched_item(item_code, from_warehouses, required_qty)
+	else:
+		return get_available_item_locations_for_other_item(item_code, from_warehouses, required_qty)
+
+def get_available_item_locations_for_serialized_item(item_code, from_warehouses, required_qty):
 	filters = frappe._dict({
 		'item_code': item_code,
-		'actual_qty': ['>', 0]
+		'warehouse': ['!=', '']
 	})
+
 	if from_warehouses:
 		filters.warehouse = ['in', from_warehouses]
 
-	available_items = frappe.get_all('Bin',
-		fields=['warehouse', 'actual_qty as qty'],
-		filters=filters,
-		order_by='creation')
-
-	return available_items
-
-def get_item_locations_based_on_serial_nos(item_doc):
 	serial_nos = frappe.get_all('Serial No',
-		fields = ['name', 'warehouse'],
-		filters = {
-			'item_code': item_doc.item_code,
-			'warehouse': ['!=', '']
-		}, limit=item_doc.stock_qty, order_by='purchase_date', as_list=1)
+		fields=['name', 'warehouse'],
+		filters=filters,
+		limit=required_qty,
+		order_by='purchase_date',
+		as_list=1)
 
-	remaining_stock_qty = flt(item_doc.stock_qty) - len(serial_nos)
+	remaining_stock_qty = required_qty - len(serial_nos)
 	if remaining_stock_qty:
-		frappe.msgprint('{0} {1} of {2} is not available.'
-			.format(remaining_stock_qty, item_doc.stock_uom, item_doc.item_code))
+		frappe.msgprint('{0} qty of {1} is not available.'
+			.format(remaining_stock_qty, item_code))
 
 	warehouse_serial_nos_map = frappe._dict()
 	for serial_no, warehouse in serial_nos:
@@ -130,12 +155,12 @@
 		locations.append({
 			'qty': len(serial_nos),
 			'warehouse': warehouse,
-			'serial_no': '\n'.join(serial_nos)
+			'serial_no': serial_nos
 		})
 
 	return locations
 
-def get_item_locations_based_on_batch_nos(item_doc):
+def get_available_item_locations_for_batched_item(item_code, from_warehouses, required_qty):
 	batch_locations = frappe.db.sql("""
 		SELECT
 			sle.`warehouse`,
@@ -154,30 +179,36 @@
 		HAVING `qty` > 0
 		ORDER BY IFNULL(batch.`expiry_date`, '2200-01-01'), batch.`creation`
 	""", {
-		'item_code': item_doc.item_code,
+		'item_code': item_code,
 		'today': today()
 	}, as_dict=1)
 
-	locations = []
-	required_qty = item_doc.stock_qty
+	total_qty_available = sum(location.get('qty') for location in batch_locations)
 
-	for batch_location in batch_locations:
-		if batch_location.qty >= required_qty:
-			# this batch should fulfill the required items
-			batch_location.qty = required_qty
-			required_qty = 0
-		else:
-			required_qty -= batch_location.qty
+	remaining_qty = required_qty - total_qty_available
 
-		locations.append(batch_location)
+	if remaining_qty > 0:
+		frappe.msgprint('No batches found for {} qty of {}.'.format(remaining_qty, item_code))
 
-		if required_qty <= 0:
-			break
+	return batch_locations
 
-	if required_qty:
-		frappe.msgprint('No batches found for {} qty of {}.'.format(required_qty, item_doc.item_code))
+def get_available_item_locations_for_other_item(item_code, from_warehouses, required_qty):
+	# gets all items available in different warehouses
+	filters = frappe._dict({
+		'item_code': item_code,
+		'actual_qty': ['>', 0]
+	})
 
-	return locations
+	if from_warehouses:
+		filters.warehouse = ['in', from_warehouses]
+
+	item_locations = frappe.get_all('Bin',
+		fields=['warehouse', 'actual_qty as qty'],
+		filters=filters,
+		limit=required_qty,
+		order_by='creation')
+
+	return item_locations
 
 @frappe.whitelist()
 def create_delivery_note(source_name, target_doc=None):
@@ -342,14 +373,8 @@
 
 	for location in pick_list.locations:
 		item = frappe._dict()
-		item.item_code = location.item_code
-		item.s_warehouse = location.warehouse
+		update_common_item_properties(item, location)
 		item.t_warehouse = wip_warehouse
-		item.qty = location.picked_qty * location.conversion_factor
-		item.transfer_qty = location.picked_qty
-		item.uom = location.uom
-		item.conversion_factor = location.conversion_factor
-		item.stock_uom = location.stock_uom
 
 		stock_entry.append('items', item)
 
@@ -362,17 +387,8 @@
 			target_warehouse = frappe.get_value('Material Request Item',
 				location.material_request_item, 'warehouse')
 		item = frappe._dict()
-		item.item_code = location.item_code
-		item.s_warehouse = location.warehouse
+		update_common_item_properties(item, location)
 		item.t_warehouse = target_warehouse
-		item.qty = location.picked_qty * location.conversion_factor
-		item.transfer_qty = location.picked_qty
-		item.uom = location.uom
-		item.conversion_factor = location.conversion_factor
-		item.stock_uom = location.stock_uom
-		item.material_request = location.material_request
-		item.material_request_item = location.material_request_item
-
 		stock_entry.append('items', item)
 
 	return stock_entry
@@ -380,16 +396,21 @@
 def update_stock_entry_items_with_no_reference(pick_list, stock_entry):
 	for location in pick_list.locations:
 		item = frappe._dict()
-		item.item_code = location.item_code
-		item.s_warehouse = location.warehouse
-		item.qty = location.picked_qty * location.conversion_factor
-		item.transfer_qty = location.picked_qty
-		item.uom = location.uom
-		item.conversion_factor = location.conversion_factor
-		item.stock_uom = location.stock_uom
-		item.material_request = location.material_request
-		item.material_request_item = location.material_request_item
+		update_common_item_properties(item, location)
 
 		stock_entry.append('items', item)
 
-	return stock_entry
\ No newline at end of file
+	return stock_entry
+
+def update_common_item_properties(item, location):
+	item.item_code = location.item_code
+	item.s_warehouse = location.warehouse
+	item.qty = location.picked_qty * location.conversion_factor
+	item.transfer_qty = location.picked_qty
+	item.uom = location.uom
+	item.conversion_factor = location.conversion_factor
+	item.stock_uom = location.stock_uom
+	item.material_request = location.material_request
+	item.serial_no = location.serial_no
+	item.batch_no = location.batch_no
+	item.material_request_item = location.material_request_item
\ No newline at end of file