Merge pull request #30238 from KrithiRamani/bulk_create_PL_and_DN

feat: Create single PL/DN from several SO. 
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index 7e99a06..fe2f14e 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.json
+++ b/erpnext/selling/doctype/sales_order/sales_order.json
@@ -130,6 +130,7 @@
   "per_delivered",
   "column_break_81",
   "per_billed",
+  "per_picked",
   "billing_status",
   "sales_team_section_break",
   "sales_partner",
@@ -1514,13 +1515,19 @@
    "fieldtype": "Currency",
    "label": "Amount Eligible for Commission",
    "read_only": 1
+  },
+  {
+   "fieldname": "per_picked",
+   "fieldtype": "Percent",
+   "label": "% Picked",
+   "read_only": 1
   }
  ],
  "icon": "fa fa-file-text",
  "idx": 105,
  "is_submittable": 1,
  "links": [],
- "modified": "2021-10-05 12:16:40.775704",
+ "modified": "2022-03-15 21:38:31.437586",
  "modified_by": "Administrator",
  "module": "Selling",
  "name": "Sales Order",
@@ -1594,6 +1601,7 @@
  "show_name_in_global_search": 1,
  "sort_field": "modified",
  "sort_order": "DESC",
+ "states": [],
  "timeline_field": "customer",
  "title_field": "customer_name",
  "track_changes": 1,
diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
index 7e55499..195e964 100644
--- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json
+++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
@@ -23,6 +23,7 @@
   "quantity_and_rate",
   "qty",
   "stock_uom",
+  "picked_qty",
   "col_break2",
   "uom",
   "conversion_factor",
@@ -798,12 +799,17 @@
    "fieldtype": "Check",
    "label": "Grant Commission",
    "read_only": 1
+  },
+  {
+   "fieldname": "picked_qty",
+   "fieldtype": "Float",
+   "label": "Picked Qty"
   }
  ],
  "idx": 1,
  "istable": 1,
  "links": [],
- "modified": "2022-02-24 14:41:57.325799",
+ "modified": "2022-03-15 20:17:33.984799",
  "modified_by": "Administrator",
  "module": "Selling",
  "name": "Sales Order Item",
diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js
index 730fd7a..13b74b5 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.js
+++ b/erpnext/stock/doctype/pick_list/pick_list.js
@@ -146,10 +146,6 @@
 			customer: frm.doc.customer
 		};
 		frm.get_items_btn = frm.add_custom_button(__('Get Items'), () => {
-			if (!frm.doc.customer) {
-				frappe.msgprint(__('Please select Customer first'));
-				return;
-			}
 			erpnext.utils.map_current_doc({
 				method: 'erpnext.selling.doctype.sales_order.sales_order.create_pick_list',
 				source_doctype: 'Sales Order',
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index b2eaecb..3a49686 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -3,6 +3,8 @@
 
 import json
 from collections import OrderedDict, defaultdict
+from itertools import groupby
+from operator import itemgetter
 
 import frappe
 from frappe import _
@@ -24,8 +26,21 @@
 	def before_save(self):
 		self.set_item_locations()
 
+		# set percentage picked in SO
+		for location in self.get('locations'):
+			if location.sales_order and frappe.db.get_value("Sales Order",location.sales_order,"per_picked") == 100:
+				frappe.throw("Row " + str(location.idx) + " has been picked already!")
+
 	def before_submit(self):
 		for item in self.locations:
+			# if the user has not entered any picked qty, set it to stock_qty, before submit
+			if item.picked_qty == 0:
+				item.picked_qty = item.stock_qty
+
+			if item.sales_order_item:
+				# update the picked_qty in SO Item
+				self.update_so(item.sales_order_item,item.picked_qty,item.item_code)
+
 			if not frappe.get_cached_value('Item', item.item_code, 'has_serial_no'):
 				continue
 			if not item.serial_no:
@@ -37,6 +52,32 @@
 			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)), title=_("Quantity Mismatch"))
 
+	def before_cancel(self):
+		#update picked_qty in SO Item on cancel of PL
+		for location in self.get('locations'):
+			if location.sales_order_item:
+				self.update_so(location.sales_order_item,0,location.item_code)
+
+	def update_so(self,so_item,picked_qty,item_code):
+		so_doc = frappe.get_doc("Sales Order",frappe.db.get_value("Sales Order Item",so_item,"parent"))
+		already_picked,actual_qty = frappe.db.get_value("Sales Order Item",so_item,["picked_qty","qty"])
+
+		if self.docstatus == 1:
+			if (((already_picked + picked_qty)/ actual_qty)*100) > (100 + flt(frappe.db.get_single_value('Stock Settings', 'over_delivery_receipt_allowance'))):
+				frappe.throw('You are picking more than required quantity for ' + item_code + '. Check if there is any other pick list created for '+so_doc.name)
+
+		frappe.db.set_value("Sales Order Item",so_item,"picked_qty",already_picked+picked_qty)
+
+		total_picked_qty = 0
+		total_so_qty = 0
+		for item in so_doc.get('items'):
+			total_picked_qty += flt(item.picked_qty)
+			total_so_qty += flt(item.stock_qty)
+		total_picked_qty=total_picked_qty + picked_qty
+		per_picked = total_picked_qty/total_so_qty * 100
+
+		so_doc.db_set("per_picked", flt(per_picked) ,update_modified=False)
+
 	@frappe.whitelist()
 	def set_item_locations(self, save=False):
 		self.validate_for_qty()
@@ -64,10 +105,6 @@
 			item_doc.name = None
 
 			for row in locations:
-				row.update({
-					'picked_qty': row.stock_qty
-				})
-
 				location = item_doc.as_dict()
 				location.update(row)
 				self.append('locations', location)
@@ -340,63 +377,102 @@
 def create_delivery_note(source_name, target_doc=None):
 	pick_list = frappe.get_doc('Pick List', source_name)
 	validate_item_locations(pick_list)
-
-	sales_orders = [d.sales_order for d in pick_list.locations if d.sales_order]
-	sales_orders = set(sales_orders)
-
+	sales_dict = dict()
+	sales_orders = []
 	delivery_note = None
-	for sales_order in sales_orders:
-		delivery_note = create_delivery_note_from_sales_order(sales_order,
-			delivery_note, skip_item_mapping=True)
+	for location in pick_list.locations:
+		if location.sales_order:
+			sales_orders.append([frappe.db.get_value("Sales Order",location.sales_order,'customer'),location.sales_order])
+	# Group sales orders by customer
+	for key,keydata in groupby(sales_orders,key=itemgetter(0)):
+		sales_dict[key] = set([d[1] for d in keydata])
 
-	# map rows without sales orders as well
-	if not delivery_note:
+	if sales_dict:
+		delivery_note = create_dn_with_so(sales_dict,pick_list)
+
+	is_item_wo_so = 0
+	for location in pick_list.locations :
+		if not location.sales_order:
+			is_item_wo_so = 1
+			break
+	if is_item_wo_so == 1:
+		# Create a DN for items without sales orders as well
+		delivery_note = create_dn_wo_so(pick_list)
+
+	frappe.msgprint(_('Delivery Note(s) created for the Pick List'))
+	return delivery_note
+
+def create_dn_wo_so(pick_list):
 		delivery_note = frappe.new_doc("Delivery Note")
 
-	item_table_mapper = {
-		'doctype': 'Delivery Note Item',
-		'field_map': {
-			'rate': 'rate',
-			'name': 'so_detail',
-			'parent': 'against_sales_order',
-		},
-		'condition': lambda doc: abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier!=1
-	}
-
-	item_table_mapper_without_so = {
-		'doctype': 'Delivery Note Item',
-		'field_map': {
-			'rate': 'rate',
-			'name': 'name',
-			'parent': '',
+		item_table_mapper_without_so = {
+			'doctype': 'Delivery Note Item',
+			'field_map': {
+				'rate': 'rate',
+				'name': 'name',
+				'parent': '',
+			}
 		}
-	}
+		map_pl_locations(pick_list,item_table_mapper_without_so,delivery_note)
+		delivery_note.insert(ignore_mandatory = True)
+
+		return delivery_note
+
+
+def create_dn_with_so(sales_dict,pick_list):
+	delivery_note = None
+
+	for customer in sales_dict:
+		for so in sales_dict[customer]:
+			delivery_note = None
+			delivery_note = create_delivery_note_from_sales_order(so,
+				delivery_note, skip_item_mapping=True)
+
+			item_table_mapper = {
+				'doctype': 'Delivery Note Item',
+				'field_map': {
+					'rate': 'rate',
+					'name': 'so_detail',
+					'parent': 'against_sales_order',
+				},
+				'condition': lambda doc: abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier!=1
+			}
+			break
+		if delivery_note:
+			# map all items of all sales orders of that customer
+			for so in sales_dict[customer]:
+				map_pl_locations(pick_list,item_table_mapper,delivery_note,so)
+			delivery_note.insert(ignore_mandatory = True)
+
+	return delivery_note
+
+def map_pl_locations(pick_list,item_mapper,delivery_note,sales_order = None):
 
 	for location in pick_list.locations:
-		if location.sales_order_item:
-			sales_order_item = frappe.get_cached_doc('Sales Order Item', {'name':location.sales_order_item})
-		else:
-			sales_order_item = None
+		if location.sales_order == sales_order:
+			if location.sales_order_item:
+				sales_order_item = frappe.get_cached_doc('Sales Order Item', {'name':location.sales_order_item})
+			else:
+				sales_order_item = None
 
-		source_doc, table_mapper = [sales_order_item, item_table_mapper] if sales_order_item \
-			else [location, item_table_mapper_without_so]
+			source_doc, table_mapper = [sales_order_item, item_mapper] if sales_order_item \
+				else [location, item_mapper]
 
-		dn_item = map_child_doc(source_doc, delivery_note, table_mapper)
+			dn_item = map_child_doc(source_doc, delivery_note, table_mapper)
 
-		if dn_item:
-			dn_item.warehouse = location.warehouse
-			dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1)
-			dn_item.batch_no = location.batch_no
-			dn_item.serial_no = location.serial_no
+			if dn_item:
+				dn_item.warehouse = location.warehouse
+				dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1)
+				dn_item.batch_no = location.batch_no
+				dn_item.serial_no = location.serial_no
 
-			update_delivery_note_item(source_doc, dn_item, delivery_note)
-
+				update_delivery_note_item(source_doc, dn_item, delivery_note)
 	set_delivery_note_missing_values(delivery_note)
 
 	delivery_note.pick_list = pick_list.name
-	delivery_note.customer = pick_list.customer if pick_list.customer else None
+	delivery_note.company = pick_list.company
+	delivery_note.customer = frappe.get_value("Sales Order",sales_order,"customer")
 
-	return delivery_note
 
 @frappe.whitelist()
 def create_stock_entry(pick_list):
@@ -561,4 +637,4 @@
 	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
+	item.material_request_item = location.material_request_item
\ No newline at end of file
diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py
index f3b6b89..f60104c 100644
--- a/erpnext/stock/doctype/pick_list/test_pick_list.py
+++ b/erpnext/stock/doctype/pick_list/test_pick_list.py
@@ -17,7 +17,6 @@
 
 
 class TestPickList(FrappeTestCase):
-
 	def test_pick_list_picks_warehouse_for_each_item(self):
 		try:
 			frappe.get_doc({
@@ -188,7 +187,6 @@
 			}]
 		})
 		pick_list.set_item_locations()
-
 		self.assertEqual(pick_list.locations[0].batch_no, oldest_batch_no)
 
 		pr1.cancel()
@@ -311,6 +309,7 @@
 					'item_code': '_Test Item',
 					'qty': 1,
 					'conversion_factor': 5,
+					'stock_qty':5,
 					'delivery_date': frappe.utils.today()
 				}, {
 					'item_code': '_Test Item',
@@ -329,9 +328,9 @@
 			'purpose': 'Delivery',
 			'locations': [{
 				'item_code': '_Test Item',
-				'qty': 1,
-				'stock_qty': 5,
-				'conversion_factor': 5,
+				'qty': 2,
+				'stock_qty': 1,
+				'conversion_factor': 0.5,
 				'sales_order': sales_order.name,
 				'sales_order_item': sales_order.items[0].name ,
 			}, {
@@ -389,6 +388,95 @@
 		for expected_item, created_item in zip(expected_items, pl.locations):
 			_compare_dicts(expected_item, created_item)
 
+	def test_multiple_dn_creation(self):
+		sales_order_1 = frappe.get_doc({
+				'doctype': 'Sales Order',
+				'customer': '_Test Customer',
+				'company': '_Test Company',
+				'items': [{
+					'item_code': '_Test Item',
+					'qty': 1,
+					'conversion_factor': 1,
+					'delivery_date': frappe.utils.today()
+				}],
+			}).insert()
+		sales_order_1.submit()
+		sales_order_2 = frappe.get_doc({
+				'doctype': 'Sales Order',
+				'customer': '_Test Customer 1',
+				'company': '_Test Company',
+				'items': [{
+					'item_code': '_Test Item 2',
+					'qty': 1,
+					'conversion_factor': 1,
+					'delivery_date': frappe.utils.today()
+				},
+				],
+			}).insert()
+		sales_order_2.submit()
+		pick_list = frappe.get_doc({
+			'doctype': 'Pick List',
+			'company': '_Test Company',
+			'items_based_on': 'Sales Order',
+			'purpose': 'Delivery',
+			'picker':'P001',
+			'locations': [{
+				'item_code': '_Test Item ',
+				'qty': 1,
+				'stock_qty': 1,
+				'conversion_factor': 1,
+				'sales_order': sales_order_1.name,
+				'sales_order_item': sales_order_1.items[0].name ,
+			}, {
+				'item_code': '_Test Item 2',
+				'qty': 1,
+				'stock_qty': 1,
+				'conversion_factor': 1,
+				'sales_order': sales_order_2.name,
+				'sales_order_item': sales_order_2.items[0].name ,
+			}
+			]
+		})
+		pick_list.set_item_locations()
+		pick_list.submit()
+		create_delivery_note(pick_list.name)
+		for dn in frappe.get_all("Delivery Note",filters={"pick_list":pick_list.name,"customer":"_Test Customer"},fields={"name"}):
+			for dn_item in frappe.get_doc("Delivery Note",dn.name).get("items"):
+				self.assertEqual(dn_item.item_code, '_Test Item')
+				self.assertEqual(dn_item.against_sales_order,sales_order_1.name)
+		for dn in frappe.get_all("Delivery Note",filters={"pick_list":pick_list.name,"customer":"_Test Customer 1"},fields={"name"}):
+			for dn_item in frappe.get_doc("Delivery Note",dn.name).get("items"):
+				self.assertEqual(dn_item.item_code, '_Test Item 2')
+				self.assertEqual(dn_item.against_sales_order,sales_order_2.name)
+		#test DN creation without so
+		pick_list_1 = frappe.get_doc({
+			'doctype': 'Pick List',
+			'company': '_Test Company',
+			'purpose': 'Delivery',
+			'picker':'P001',
+			'locations': [{
+				'item_code': '_Test Item ',
+				'qty': 1,
+				'stock_qty': 1,
+				'conversion_factor': 1,
+			}, {
+				'item_code': '_Test Item 2',
+				'qty': 2,
+				'stock_qty': 2,
+				'conversion_factor': 1,
+			}
+			]
+		})
+		pick_list_1.set_item_locations()
+		pick_list_1.submit()
+		create_delivery_note(pick_list_1.name)
+		for dn in frappe.get_all("Delivery Note",filters={"pick_list":pick_list_1.name},fields={"name"}):
+			for dn_item in frappe.get_doc("Delivery Note",dn.name).get("items"):
+				if dn_item.item_code == '_Test Item':
+					self.assertEqual(dn_item.qty,1)
+				if dn_item.item_code == '_Test Item 2':
+					self.assertEqual(dn_item.qty,2)
+
 	# def test_pick_list_skips_items_in_expired_batch(self):
 	# 	pass