feat: item-wise negative stock setting (#29761)

diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index 97d34e0..5229d87 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -172,9 +172,10 @@
 			frappe.throw(error_msg, title=_("Invalid Item"), as_list=True)
 
 	def validate_stock_availablility(self):
+		from erpnext.stock.stock_ledger import is_negative_stock_allowed
+
 		if self.is_return or self.docstatus != 1:
 			return
-		allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock')
 		for d in self.get('items'):
 			is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item'))
 			if is_service_item:
@@ -186,7 +187,7 @@
 			elif d.batch_no:
 				self.validate_pos_reserved_batch_qty(d)
 			else:
-				if allow_negative_stock:
+				if is_negative_stock_allowed(item_code=d.item_code):
 					return
 
 				available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse)
diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json
index b05f58a..c797187 100644
--- a/erpnext/stock/doctype/item/item.json
+++ b/erpnext/stock/doctype/item/item.json
@@ -48,6 +48,7 @@
   "warranty_period",
   "weight_per_unit",
   "weight_uom",
+  "allow_negative_stock",
   "reorder_section",
   "reorder_levels",
   "unit_of_measure_conversion",
@@ -907,6 +908,12 @@
    "fieldname": "is_grouped_asset",
    "fieldtype": "Check",
    "label": "Create Grouped Asset"
+  },
+  {
+   "default": "0",
+   "fieldname": "allow_negative_stock",
+   "fieldtype": "Check",
+   "label": "Allow Negative Stock"
   }
  ],
  "icon": "fa fa-tag",
@@ -914,7 +921,7 @@
  "image_field": "image",
  "index_web_pages_for_search": 1,
  "links": [],
- "modified": "2022-01-18 12:57:54.273202",
+ "modified": "2022-02-11 08:07:46.663220",
  "modified_by": "Administrator",
  "module": "Stock",
  "name": "Item",
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index fc45ba9..fd4df42 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -6,6 +6,7 @@
 
 import frappe
 from frappe.test_runner import make_test_objects
+from frappe.utils import add_days, today
 
 from erpnext.controllers.item_variant import (
 	InvalidItemAttributeValueError,
@@ -608,6 +609,45 @@
 		item.item_group = "All Item Groups"
 		item.save()  # if item code saved without item_code then series worked
 
+	@change_settings("Stock Settings", {"allow_negative_stock": 0})
+	def test_item_wise_negative_stock(self):
+		""" When global settings are disabled check that item that allows
+		negative stock can still consume material in all known stock
+		transactions that consume inventory."""
+		from erpnext.stock.stock_ledger import is_negative_stock_allowed
+
+		item = make_item("_TestNegativeItemSetting", {"allow_negative_stock": 1, "valuation_rate": 100})
+		self.assertTrue(is_negative_stock_allowed(item_code=item.name))
+
+		self.consume_item_code_with_differet_stock_transactions(item_code=item.name)
+
+	@change_settings("Stock Settings", {"allow_negative_stock": 0})
+	def test_backdated_negative_stock(self):
+		""" same as test above but backdated entries """
+		from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+		item = make_item("_TestNegativeItemSetting", {"allow_negative_stock": 1, "valuation_rate": 100})
+
+		# create a future entry so all new entries are backdated
+		make_stock_entry(qty=1, item_code=item.name, target="_Test Warehouse - _TC", posting_date = add_days(today(), 5))
+		self.consume_item_code_with_differet_stock_transactions(item_code=item.name)
+
+
+	def consume_item_code_with_differet_stock_transactions(self, item_code, warehouse="_Test Warehouse - _TC"):
+		from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+		from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+		from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+		from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+
+		typical_args = {"item_code": item_code, "warehouse": warehouse}
+
+		create_delivery_note(**typical_args)
+		create_sales_invoice(update_stock=1, **typical_args)
+		make_stock_entry(item_code=item_code, source=warehouse, qty=1, purpose="Material Issue")
+		make_stock_entry(item_code=item_code, source=warehouse, target="Stores - _TC", qty=1)
+		# standalone return
+		make_purchase_receipt(is_return=True, qty=-1, **typical_args)
+
+
 
 def set_item_variant_settings(fields):
 	doc = frappe.get_doc('Item Variant Settings')
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index 782fcf0..9ba007a 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -433,9 +433,10 @@
 				)
 
 	def set_actual_qty(self):
-		allow_negative_stock = cint(frappe.db.get_value("Stock Settings", None, "allow_negative_stock"))
+		from erpnext.stock.stock_ledger import is_negative_stock_allowed
 
 		for d in self.get('items'):
+			allow_negative_stock = is_negative_stock_allowed(item_code=d.item_code)
 			previous_sle = get_previous_sle({
 				"item_code": d.item_code,
 				"warehouse": d.s_warehouse or d.t_warehouse,
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 41c4002..00ca81f 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -3,6 +3,7 @@
 
 import copy
 import json
+from typing import Optional
 
 import frappe
 from frappe import _
@@ -268,11 +269,10 @@
 		self.verbose = verbose
 		self.allow_zero_rate = allow_zero_rate
 		self.via_landed_cost_voucher = via_landed_cost_voucher
-		self.allow_negative_stock = allow_negative_stock \
-			or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
+		self.item_code = args.get("item_code")
+		self.allow_negative_stock = allow_negative_stock or is_negative_stock_allowed(item_code=self.item_code)
 
 		self.args = frappe._dict(args)
-		self.item_code = args.get("item_code")
 		if self.args.sle_id:
 			self.args['name'] = self.args.sle_id
 
@@ -1049,10 +1049,7 @@
 		)"""
 
 def validate_negative_qty_in_future_sle(args, allow_negative_stock=False):
-	allow_negative_stock = cint(allow_negative_stock) \
-		or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
-
-	if allow_negative_stock:
+	if allow_negative_stock or is_negative_stock_allowed(item_code=args.item_code):
 		return
 	if not (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation"):
 		return
@@ -1121,3 +1118,11 @@
 			and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s)
 		limit 1
 	""", args, as_dict=1)
+
+
+def is_negative_stock_allowed(*, item_code: Optional[str] = None) -> bool:
+	if cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock", cache=True)):
+		return True
+	if item_code and cint(frappe.db.get_value("Item", item_code, "allow_negative_stock", cache=True)):
+		return True
+	return False