feat: Apply Putaway Rules within transaction itself

- Added checkbox 'Apply Putaway Rule' in PR and SE
- Added link to rule in child tables
- Rule is applied on Save
- Validation for over receipt
- Apply Rule on Stock Entry as well for Material Transfer and Receipt
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index bb67eb9..d32e98e 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -349,9 +349,7 @@
 	frappe.local.message_log = []
 
 def set_missing_values(source, target):
-	from erpnext.stock.doctype.putaway_rule.putaway_rule import apply_putaway_rule
 	target.ignore_pricing_rule = 1
-	target.items = apply_putaway_rule(target.items, target.company)
 	target.run_method("set_missing_values")
 	target.run_method("calculate_taxes_and_totals")
 
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 2d2fff8..c7fadde 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -6,6 +6,7 @@
 from frappe.utils import cint, flt, cstr, get_link_to_form, today, getdate
 from frappe import _
 import frappe.defaults
+from collections import defaultdict
 from erpnext.accounts.utils import get_fiscal_year
 from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries, process_gl_map
 from erpnext.controllers.accounts_controller import AccountsController
@@ -23,6 +24,7 @@
 			self.validate_inspection()
 		self.validate_serialized_batch()
 		self.validate_customer_provided_item()
+		self.validate_putaway_capacity()
 
 	def make_gl_entries(self, gl_entries=None):
 		if self.docstatus == 2:
@@ -399,6 +401,42 @@
 			if frappe.db.get_value('Item', d.item_code, 'is_customer_provided_item'):
 				d.allow_zero_valuation_rate = 1
 
+	def validate_putaway_capacity(self):
+		# if over receipt is attempted while 'apply putaway rule' is disabled
+		# and if rule was applied on the transaction, validate it.
+		from erpnext.stock.doctype.putaway_rule.putaway_rule import get_putaway_capacity
+		valid_doctype = self.doctype in ("Purchase Receipt", "Stock Entry")
+		rule_applied = any(item.get("putaway_rule") for item in self.get("items"))
+
+		if valid_doctype and rule_applied and not self.apply_putaway_rule:
+			rule_map = defaultdict(dict)
+			for item in self.get("items"):
+				if item.get("putaway_rule"):
+					rule = item.get("putaway_rule")
+					disabled = frappe.db.get_value("Putaway Rule", rule, "disable")
+					if disabled: return # dont validate for disabled rule
+					stock_qty = flt(item.transfer_qty) if self.doctype == "Stock Entry" else flt(item.stock_qty)
+					warehouse_field = "t_warehouse" if self.doctype == "Stock Entry" else "warehouse"
+					if not rule_map[rule]:
+						rule_map[rule]["warehouse"] = item.get(warehouse_field)
+						rule_map[rule]["item"] = item.get("item_code")
+						rule_map[rule]["qty_put"] = 0
+						rule_map[rule]["capacity"] = get_putaway_capacity(rule)
+					rule_map[rule]["qty_put"] += flt(stock_qty)
+
+			for rule, values in rule_map.items():
+				if flt(values["qty_put"]) > flt(values["capacity"]):
+					message = _("{0} qty of Item {1} is being received into Warehouse {2} with capacity {3}.") \
+						.format(
+							frappe.bold(values["qty_put"]), frappe.bold(values["item"]),
+							frappe.bold(values["warehouse"]), frappe.bold(values["capacity"])
+						)
+					message += "<br><br>"
+					rule_link = frappe.utils.get_link_to_form("Putaway Rule", rule)
+					message += _(" Please adjust the qty or edit {0} to proceed.").format(rule_link)
+					frappe.throw(msg=message, title=_("Over Receipt"))
+			return rule_map
+
 def compare_existing_and_expected_gle(existing_gle, expected_gle):
 	matched = True
 	for entry in expected_gle:
diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js
index 58ac38f..ac48d45 100644
--- a/erpnext/public/js/controllers/buying.js
+++ b/erpnext/public/js/controllers/buying.js
@@ -524,3 +524,26 @@
 
 	dialog.show();
 }
+
+erpnext.apply_putaway_rule = (frm) => {
+	if (!frm.doc.company) {
+		frappe.throw({message:__("Please select a Company first."), title: __("Mandatory")})
+	}
+	if (!frm.doc.items.length) return;
+
+	frappe.call({
+		method: "erpnext.stock.doctype.putaway_rule.putaway_rule.apply_putaway_rule",
+		args: {
+			items: frm.doc.items,
+			company: frm.doc.company
+		},
+		callback: (result) => {
+			if(!result.exc) {
+				if(result.message) {
+					frm.doc.items = result.message;
+					frm.get_field("items").refresh();
+				}
+			}
+		}
+	});
+}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
index bc1d81d..45eb646 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
@@ -213,6 +213,10 @@
 		});
 	},
 
+	apply_putaway_rule: function() {
+		// if (this.frm.doc.apply_putaway_rule) erpnext.apply_putaway_rule(this.frm);
+	}
+
 });
 
 // for backward compatibility: combine new and previous states
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index 2cc4679..511bae6 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -70,6 +70,12 @@
 					where name=`tabPurchase Invoice Item`.parent and is_return=1 and update_stock=1)"""
 			})
 
+	def before_save(self):
+		from erpnext.stock.doctype.putaway_rule.putaway_rule import apply_putaway_rule
+
+		if self.get("items") and self.apply_putaway_rule:
+			self.items = apply_putaway_rule(self.doctype, self.get("items"), self.company)
+
 	def validate(self):
 		self.validate_posting_time()
 		super(PurchaseReceipt, self).validate()
@@ -90,6 +96,7 @@
 		if getdate(self.posting_date) > getdate(nowdate()):
 			throw(_("Posting Date cannot be future date"))
 
+
 	def validate_cwip_accounts(self):
 		for item in self.get('items'):
 			if item.is_fixed_asset and is_cwip_accounting_enabled(item.asset_category):
diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
index c1e1f90..fcbf6cc 100644
--- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
+++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
@@ -72,6 +72,7 @@
   "purchase_order_item",
   "material_request_item",
   "purchase_receipt_item",
+  "putaway_rule",
   "section_break_45",
   "allow_zero_valuation_rate",
   "bom",
@@ -834,12 +835,21 @@
    "collapsible": 1,
    "fieldname": "image_column",
    "fieldtype": "Column Break"
+  },
+  {
+   "fieldname": "putaway_rule",
+   "fieldtype": "Link",
+   "label": "Putaway Rule",
+   "no_copy": 1,
+   "options": "Putaway Rule",
+   "print_hide": 1,
+   "read_only": 1
   }
  ],
  "idx": 1,
  "istable": 1,
  "links": [],
- "modified": "2020-04-28 19:01:21.154963",
+ "modified": "2020-11-26 12:16:14.897160",
  "modified_by": "Administrator",
  "module": "Stock",
  "name": "Purchase Receipt Item",
diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.py b/erpnext/stock/doctype/putaway_rule/putaway_rule.py
index 606e190..8838bb7 100644
--- a/erpnext/stock/doctype/putaway_rule/putaway_rule.py
+++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.py
@@ -5,11 +5,14 @@
 from __future__ import unicode_literals
 import frappe
 import copy
+import json
 from collections import defaultdict
+from six import string_types
 from frappe import _
-from frappe.utils import flt, floor, nowdate
+from frappe.utils import flt, floor, nowdate, cint
 from frappe.model.document import Document
 from erpnext.stock.utils import get_stock_balance
+from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
 
 class PutawayRule(Document):
 	def validate(self):
@@ -52,70 +55,50 @@
 	def set_stock_capacity(self):
 		self.stock_capacity = (flt(self.conversion_factor) or 1) * flt(self.capacity)
 
-def get_ordered_putaway_rules(item_code, company):
-	"""Returns an ordered list of putaway rules to apply on an item."""
-	rules = frappe.get_all("Putaway Rule",
-		fields=["name", "item_code", "stock_capacity", "priority", "warehouse"],
-		filters={"item_code": item_code, "company": company, "disable": 0},
-		order_by="priority asc, capacity desc")
-
-	if not rules:
-		return False, None
-
-	for rule in rules:
-		balance_qty = get_stock_balance(rule.item_code, rule.warehouse, nowdate())
-		free_space = flt(rule.stock_capacity) - flt(balance_qty)
-		if free_space > 0:
-			rule["free_space"] = free_space
-		else:
-			del rule
-
-	if not rules:
-		# After iterating through rules, if no rules are left
-		# then there is not enough space left in any rule
-		return True, None
-
-	rules = sorted(rules, key = lambda i: (i['priority'], -i['free_space']))
-	return False, rules
+@frappe.whitelist()
+def get_putaway_capacity(rule):
+	stock_capacity, item_code, warehouse = frappe.db.get_value("Putaway Rule", rule,
+		["stock_capacity", "item_code", "warehouse"])
+	balance_qty = get_stock_balance(item_code, warehouse, nowdate())
+	free_space = flt(stock_capacity) - flt(balance_qty)
+	return free_space if free_space > 0 else 0
 
 @frappe.whitelist()
-def apply_putaway_rule(items, company):
+def apply_putaway_rule(doctype, items, company):
 	""" Applies Putaway Rule on line items.
 
 		items: List of Purchase Receipt Item objects
 		company: Company in the Purchase Receipt
 	"""
+	if isinstance(items, string_types):
+		items = json.loads(items)
+
 	items_not_accomodated, updated_table = [], []
 	item_wise_rules = defaultdict(list)
 
-	def add_row(item, to_allocate, warehouse):
-		new_updated_table_row = copy.deepcopy(item)
-		new_updated_table_row.name = ''
-		new_updated_table_row.idx = 1 if not updated_table else flt(updated_table[-1].idx) + 1
-		new_updated_table_row.qty = to_allocate
-		new_updated_table_row.stock_qty = flt(to_allocate) * flt(new_updated_table_row.conversion_factor)
-		new_updated_table_row.warehouse = warehouse
-		updated_table.append(new_updated_table_row)
-
 	for item in items:
-		conversion = flt(item.conversion_factor)
-		uom_must_be_whole_number = frappe.db.get_value('UOM', item.uom, 'must_be_whole_number')
-		pending_qty, pending_stock_qty, item_code = flt(item.qty), flt(item.stock_qty), item.item_code
+		if isinstance(item, dict):
+			item = frappe._dict(item)
 
-		if not pending_qty:
-			add_row(item, pending_qty, item.warehouse)
+		source_warehouse = item.get("s_warehouse")
+		serial_nos = get_serial_nos(item.get("serial_no"))
+		conversion = flt(item.conversion_factor) or 1
+		pending_qty, item_code = flt(item.qty), item.item_code
+		pending_stock_qty = flt(item.transfer_qty) if doctype == "Stock Entry" else flt(item.stock_qty)
+		if not pending_qty or not item_code:
+			updated_table = add_row(item, pending_qty, item.warehouse, updated_table)
 			continue
 
-		at_capacity, rules = get_ordered_putaway_rules(item_code, company)
+		uom_must_be_whole_number = frappe.db.get_value('UOM', item.uom, 'must_be_whole_number')
+
+		at_capacity, rules = get_ordered_putaway_rules(item_code, company, source_warehouse=source_warehouse)
 
 		if not rules:
+			warehouse = item.warehouse
 			if at_capacity:
-				# rules available, but no free space
-				add_row(item, pending_qty, '')
+				warehouse = '' # rules available, but no free space
 				items_not_accomodated.append([item_code, pending_qty])
-			else:
-				# no rules to apply
-				add_row(item, pending_qty, item.warehouse)
+			updated_table = add_row(item, pending_qty, warehouse, updated_table)
 			continue
 
 		# maintain item wise rules, to handle if item is entered twice
@@ -126,7 +109,7 @@
 		for rule in item_wise_rules[item_code]:
 			if pending_stock_qty > 0 and rule.free_space:
 				stock_qty_to_allocate = flt(rule.free_space) if pending_stock_qty >= flt(rule.free_space) else pending_stock_qty
-				qty_to_allocate = stock_qty_to_allocate / (conversion or 1)
+				qty_to_allocate = stock_qty_to_allocate / (conversion)
 
 				if uom_must_be_whole_number:
 					qty_to_allocate = floor(qty_to_allocate)
@@ -134,7 +117,8 @@
 
 				if not qty_to_allocate: break
 
-				add_row(item, qty_to_allocate, rule.warehouse)
+				updated_table = add_row(item, qty_to_allocate, rule.warehouse, updated_table,
+					rule.name, serial_nos=serial_nos)
 
 				pending_stock_qty -= stock_qty_to_allocate
 				pending_qty -= qty_to_allocate
@@ -144,15 +128,71 @@
 
 		# if pending qty after applying all rules, add row without warehouse
 		if pending_stock_qty > 0:
-			add_row(item, pending_qty, '')
+			# updated_table = add_row(item, pending_qty, '', updated_table, serial_nos=serial_nos)
 			items_not_accomodated.append([item.item_code, pending_qty])
 
 	if items_not_accomodated:
-		format_unassigned_items_error(items_not_accomodated)
+		show_unassigned_items_message(items_not_accomodated)
 
 	return updated_table if updated_table else items
 
-def format_unassigned_items_error(items_not_accomodated):
+def get_ordered_putaway_rules(item_code, company, source_warehouse=None):
+	"""Returns an ordered list of putaway rules to apply on an item."""
+	filters = {
+		"item_code": item_code,
+		"company": company,
+		"disable": 0
+	}
+	if source_warehouse:
+		filters.update({"warehouse": ["!=", source_warehouse]})
+
+	rules = frappe.get_all("Putaway Rule",
+		fields=["name", "item_code", "stock_capacity", "priority", "warehouse"],
+		filters=filters,
+		order_by="priority asc, capacity desc")
+
+	if not rules:
+		return False, None
+
+	vacant_rules = []
+	for rule in rules:
+		balance_qty = get_stock_balance(rule.item_code, rule.warehouse, nowdate())
+		free_space = flt(rule.stock_capacity) - flt(balance_qty)
+		if free_space > 0:
+			rule["free_space"] = free_space
+			vacant_rules.append(rule)
+
+	if not vacant_rules:
+		# After iterating through rules, if no rules are left
+		# then there is not enough space left in any rule
+		return True, None
+
+	vacant_rules = sorted(vacant_rules, key = lambda i: (i['priority'], -i['free_space']))
+
+	return False, vacant_rules
+
+def add_row(item, to_allocate, warehouse, updated_table, rule=None, serial_nos=None):
+	new_updated_table_row = copy.deepcopy(item)
+	new_updated_table_row.idx = 1 if not updated_table else cint(updated_table[-1].idx) + 1
+	new_updated_table_row.name = "New " + str(item.doctype) + " " + str(new_updated_table_row.idx)
+	new_updated_table_row.qty = to_allocate
+	new_updated_table_row.stock_qty = flt(to_allocate) * flt(new_updated_table_row.conversion_factor)
+	if item.doctype == "Stock Entry Detail":
+		new_updated_table_row.t_warehouse = warehouse
+	else:
+		new_updated_table_row.warehouse = warehouse
+		new_updated_table_row.rejected_qty = 0
+		new_updated_table_row.received_qty = to_allocate
+
+	if rule:
+		new_updated_table_row.putaway_rule = rule
+	if serial_nos:
+		new_updated_table_row.serial_no = get_serial_nos_to_allocate(serial_nos, to_allocate)
+
+	updated_table.append(new_updated_table_row)
+	return updated_table
+
+def show_unassigned_items_message(items_not_accomodated):
 	msg = _("The following Items, having Putaway Rules, could not be accomodated:") + "<br><br>"
 	formatted_item_rows = ""
 
@@ -173,4 +213,11 @@
 		</table>
 	""".format(_("Item"), _("Unassigned Qty"), formatted_item_rows)
 
-	frappe.msgprint(msg, title=_("Insufficient Capacity"), is_minimizable=True, wide=True)
\ No newline at end of file
+	frappe.msgprint(msg, title=_("Insufficient Capacity"), is_minimizable=True, wide=True)
+
+def get_serial_nos_to_allocate(serial_nos, to_allocate):
+	if serial_nos:
+		allocated_serial_nos = serial_nos[0: cint(to_allocate)]
+		serial_nos[:] = serial_nos[cint(to_allocate):] # pop out allocated serial nos and modify list
+		return "\n".join(allocated_serial_nos) if allocated_serial_nos else ""
+	else: return ""
\ No newline at end of file
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js
index 9121758..2be70f3 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.js
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.js
@@ -571,6 +571,10 @@
 				}
 			});
 		}
+	},
+
+	apply_putaway_rule: function(frm) {
+		// if (frm.doc.apply_putaway_rule) erpnext.apply_putaway_rule(frm);
 	}
 })
 
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json
index 61e0df6..cd01fa7 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.json
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.json
@@ -27,6 +27,7 @@
   "set_posting_time",
   "inspection_required",
   "from_bom",
+  "apply_putaway_rule",
   "sb1",
   "bom_no",
   "fg_completed_qty",
@@ -640,13 +641,20 @@
    "fieldtype": "Check",
    "label": "Add to Transit",
    "no_copy": 1
+  },
+  {
+   "default": "0",
+   "depends_on": "eval:in_list([\"Material Transfer\", \"Material Receipt\"], doc.purpose)",
+   "fieldname": "apply_putaway_rule",
+   "fieldtype": "Check",
+   "label": "Apply Putaway Rule"
   }
  ],
  "icon": "fa fa-file-text",
  "idx": 1,
  "is_submittable": 1,
  "links": [],
- "modified": "2020-08-11 19:10:07.954981",
+ "modified": "2020-12-07 14:58:13.267321",
  "modified_by": "Administrator",
  "module": "Stock",
  "name": "Stock Entry",
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index e3159b9..aa3425c 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -42,6 +42,13 @@
 		for item in self.get("items"):
 			item.update(get_bin_details(item.item_code, item.s_warehouse))
 
+	def before_save(self):
+		from erpnext.stock.doctype.putaway_rule.putaway_rule import apply_putaway_rule
+		apply_rule = self.apply_putaway_rule and (self.purpose in ["Material Transfer", "Material Receipt"])
+
+		if self.get("items") and apply_rule:
+			self.items = apply_putaway_rule(self.doctype, self.get("items"), self.company)
+
 	def validate(self):
 		self.pro_doc = frappe._dict()
 		if self.work_order:
@@ -79,6 +86,7 @@
 		self.validate_serialized_batch()
 		self.set_actual_qty()
 		self.calculate_rate_and_amount(update_finished_item_rate=False)
+		self.validate_putaway_capacity()
 
 	def on_submit(self):
 
diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
index 79e8f9a..4075e28 100644
--- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
+++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
@@ -1,4 +1,5 @@
 {
+ "actions": [],
  "autoname": "hash",
  "creation": "2013-03-29 18:22:12",
  "doctype": "DocType",
@@ -61,6 +62,7 @@
   "against_stock_entry",
   "ste_detail",
   "po_detail",
+  "putaway_rule",
   "column_break_51",
   "transferred_qty",
   "reference_purchase_receipt",
@@ -498,13 +500,23 @@
    "fieldname": "set_basic_rate_manually",
    "fieldtype": "Check",
    "label": "Set Basic Rate Manually"
+  },
+  {
+   "depends_on": "eval:in_list([\"Material Transfer\", \"Material Receipt\"], parent.purpose)",
+   "fieldname": "putaway_rule",
+   "fieldtype": "Link",
+   "label": "Putaway Rule",
+   "no_copy": 1,
+   "options": "Putaway Rule",
+   "print_hide": 1,
+   "read_only": 1
   }
  ],
  "idx": 1,
  "index_web_pages_for_search": 1,
  "istable": 1,
  "links": [],
- "modified": "2020-09-23 17:55:03.384138",
+ "modified": "2020-12-07 15:00:44.489442",
  "modified_by": "Administrator",
  "module": "Stock",
  "name": "Stock Entry Detail",