Merge pull request #26342 from marination/stock-reco-repost-qty-develop

fix: Include Stock Reco logic in `update_qty_in_future_sle`
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index 2956384..3e15d54 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -357,6 +357,7 @@
 			if row.current_qty:
 				data.actual_qty = -1 * row.current_qty
 				data.qty_after_transaction = flt(row.current_qty)
+				data.previous_qty_after_transaction = flt(row.qty)
 				data.valuation_rate = flt(row.current_valuation_rate)
 				data.stock_value = data.qty_after_transaction * data.valuation_rate
 				data.stock_value_difference = -1 * flt(row.amount_difference)
diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
index 36380b8..7b98c7b 100644
--- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
@@ -6,7 +6,7 @@
 
 from __future__ import unicode_literals
 import frappe, unittest
-from frappe.utils import flt, nowdate, nowtime
+from frappe.utils import flt, nowdate, nowtime, add_days
 from erpnext.accounts.utils import get_stock_and_account_balance
 from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after
 from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import EmptyStockReconciliationItemsError, get_items
@@ -14,6 +14,7 @@
 from erpnext.stock.doctype.item.test_item import create_item
 from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method
 from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
+from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
 
 class TestStockReconciliation(unittest.TestCase):
 	@classmethod
@@ -204,6 +205,117 @@
 		self.assertEqual(sr.get("items")[0].valuation_rate, 0)
 		self.assertEqual(sr.get("items")[0].amount, 0)
 
+	def test_backdated_stock_reco_qty_reposting(self):
+		"""
+			Test if a backdated stock reco recalculates future qty until next reco.
+			-------------------------------------------
+			Var		| Doc	|	Qty	| Balance
+			-------------------------------------------
+			SR5		| Reco	|	0	|	8	(posting date: today-4) [backdated]
+			PR1		| PR	|	10	|	18	(posting date: today-3)
+			PR2		| PR	|	1	|	19	(posting date: today-2)
+			SR4		| Reco	|	0	|	6	(posting date: today-1) [backdated]
+			PR3		| PR	|	1	|	7	(posting date: today) # can't post future PR
+		"""
+		item_code = "Backdated-Reco-Item"
+		warehouse = "_Test Warehouse - _TC"
+		create_item(item_code)
+
+		pr1 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=10, rate=100,
+			posting_date=add_days(nowdate(), -3))
+		pr2 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=1, rate=100,
+			posting_date=add_days(nowdate(), -2))
+		pr3 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=1, rate=100,
+			posting_date=nowdate())
+
+		pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0},
+			"qty_after_transaction")
+		pr3_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr3.name, "is_cancelled": 0},
+			"qty_after_transaction")
+		self.assertEqual(pr1_balance, 10)
+		self.assertEqual(pr3_balance, 12)
+
+		# post backdated stock reco in between
+		sr4 = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=6, rate=100,
+			posting_date=add_days(nowdate(), -1))
+		pr3_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr3.name, "is_cancelled": 0},
+			"qty_after_transaction")
+		self.assertEqual(pr3_balance, 7)
+
+		# post backdated stock reco at the start
+		sr5 = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=8, rate=100,
+			posting_date=add_days(nowdate(), -4))
+		pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0},
+			"qty_after_transaction")
+		pr2_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr2.name, "is_cancelled": 0},
+			"qty_after_transaction")
+		sr4_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": sr4.name, "is_cancelled": 0},
+			"qty_after_transaction")
+		self.assertEqual(pr1_balance, 18)
+		self.assertEqual(pr2_balance, 19)
+		self.assertEqual(sr4_balance, 6) # check if future stock reco is unaffected
+
+		# cancel backdated stock reco and check future impact
+		sr5.cancel()
+		pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0},
+			"qty_after_transaction")
+		pr2_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr2.name, "is_cancelled": 0},
+			"qty_after_transaction")
+		sr4_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": sr4.name, "is_cancelled": 0},
+			"qty_after_transaction")
+		self.assertEqual(pr1_balance, 10)
+		self.assertEqual(pr2_balance, 11)
+		self.assertEqual(sr4_balance, 6) # check if future stock reco is unaffected
+
+		# teardown
+		sr4.cancel()
+		pr3.cancel()
+		pr2.cancel()
+		pr1.cancel()
+
+	def test_backdated_stock_reco_future_negative_stock(self):
+		"""
+			Test if a backdated stock reco causes future negative stock and is blocked.
+			-------------------------------------------
+			Var		| Doc	|	Qty	| Balance
+			-------------------------------------------
+			PR1		| PR	|	10	|	10		(posting date: today-2)
+			SR3		| Reco	|	0	|	1		(posting date: today-1) [backdated & blocked]
+			DN2		| DN	|	-2	|	8(-1)	(posting date: today)
+		"""
+		from erpnext.stock.stock_ledger import NegativeStockError
+		from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+
+		item_code = "Backdated-Reco-Item"
+		warehouse = "_Test Warehouse - _TC"
+		create_item(item_code)
+
+		negative_stock_setting = frappe.db.get_single_value("Stock Settings", "allow_negative_stock")
+		frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 0)
+
+		pr1 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=10, rate=100,
+			posting_date=add_days(nowdate(), -2))
+		dn2 = create_delivery_note(item_code=item_code, warehouse=warehouse, qty=2, rate=120,
+			posting_date=nowdate())
+
+		pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0},
+			"qty_after_transaction")
+		dn2_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": dn2.name, "is_cancelled": 0},
+			"qty_after_transaction")
+		self.assertEqual(pr1_balance, 10)
+		self.assertEqual(dn2_balance, 8)
+
+		# check if stock reco is blocked
+		sr3 = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=1, rate=100,
+			posting_date=add_days(nowdate(), -1), do_not_submit=True)
+		self.assertRaises(NegativeStockError, sr3.submit)
+
+		# teardown
+		frappe.db.set_value("Stock Settings", None, "allow_negative_stock", negative_stock_setting)
+		sr3.cancel()
+		dn2.cancel()
+		pr1.cancel()
+
 def insert_existing_sle(warehouse):
 	from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
 
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 9fe89c3..4e9c768 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -55,6 +55,11 @@
 				sle_doc = make_entry(sle, allow_negative_stock, via_landed_cost_voucher)
 
 			args = sle_doc.as_dict()
+
+			if sle.get("voucher_type") == "Stock Reconciliation":
+				# preserve previous_qty_after_transaction for qty reposting
+				args.previous_qty_after_transaction = sle.get("previous_qty_after_transaction")
+
 			update_bin(args, allow_negative_stock, via_landed_cost_voucher)
 
 def get_args_for_future_sle(row):
@@ -215,7 +220,7 @@
 		"""
 		self.data.setdefault(args.warehouse, frappe._dict())
 		warehouse_dict = self.data[args.warehouse]
-		previous_sle = self.get_previous_sle_of_current_voucher(args)
+		previous_sle = get_previous_sle_of_current_voucher(args)
 		warehouse_dict.previous_sle = previous_sle
 
 		for key in ("qty_after_transaction", "valuation_rate", "stock_value"):
@@ -227,29 +232,6 @@
 			"stock_value_difference": 0.0
 		})
 
-	def get_previous_sle_of_current_voucher(self, args):
-		"""get stock ledger entries filtered by specific posting datetime conditions"""
-
-		args['time_format'] = '%H:%i:%s'
-		if not args.get("posting_date"):
-			args["posting_date"] = "1900-01-01"
-		if not args.get("posting_time"):
-			args["posting_time"] = "00:00"
-
-		sle = frappe.db.sql("""
-			select *, timestamp(posting_date, posting_time) as "timestamp"
-			from `tabStock Ledger Entry`
-			where item_code = %(item_code)s
-				and warehouse = %(warehouse)s
-				and is_cancelled = 0
-				and timestamp(posting_date, time_format(posting_time, %(time_format)s)) < timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s))
-			order by timestamp(posting_date, posting_time) desc, creation desc
-			limit 1
-			for update""", args, as_dict=1)
-
-		return sle[0] if sle else frappe._dict()
-
-
 	def build(self):
 		from erpnext.controllers.stock_controller import future_sle_exists
 
@@ -734,6 +716,35 @@
 			bin_doc.flags.via_stock_ledger_entry = True
 			bin_doc.save(ignore_permissions=True)
 
+
+def get_previous_sle_of_current_voucher(args, exclude_current_voucher=False):
+	"""get stock ledger entries filtered by specific posting datetime conditions"""
+
+	args['time_format'] = '%H:%i:%s'
+	if not args.get("posting_date"):
+		args["posting_date"] = "1900-01-01"
+	if not args.get("posting_time"):
+		args["posting_time"] = "00:00"
+
+	voucher_condition = ""
+	if exclude_current_voucher:
+		voucher_no = args.get("voucher_no")
+		voucher_condition = f"and voucher_no != '{voucher_no}'"
+
+	sle = frappe.db.sql("""
+		select *, timestamp(posting_date, posting_time) as "timestamp"
+		from `tabStock Ledger Entry`
+		where item_code = %(item_code)s
+			and warehouse = %(warehouse)s
+			and is_cancelled = 0
+			{voucher_condition}
+			and timestamp(posting_date, time_format(posting_time, %(time_format)s)) < timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s))
+		order by timestamp(posting_date, posting_time) desc, creation desc
+		limit 1
+		for update""".format(voucher_condition=voucher_condition), args, as_dict=1)
+
+	return sle[0] if sle else frappe._dict()
+
 def get_previous_sle(args, for_update=False):
 	"""
 		get the last sle on or before the current time-bucket,
@@ -862,9 +873,24 @@
 	return valuation_rate
 
 def update_qty_in_future_sle(args, allow_negative_stock=None):
+	"""Recalculate Qty after Transaction in future SLEs based on current SLE."""
+	datetime_limit_condition = ""
+	qty_shift = args.actual_qty
+
+	# find difference/shift in qty caused by stock reconciliation
+	if args.voucher_type == "Stock Reconciliation":
+		qty_shift = get_stock_reco_qty_shift(args)
+
+	# find the next nearest stock reco so that we only recalculate SLEs till that point
+	next_stock_reco_detail = get_next_stock_reco(args)
+	if next_stock_reco_detail:
+		detail = next_stock_reco_detail[0]
+		# add condition to update SLEs before this date & time
+		datetime_limit_condition = get_datetime_limit_condition(detail)
+
 	frappe.db.sql("""
 		update `tabStock Ledger Entry`
-		set qty_after_transaction = qty_after_transaction + {qty}
+		set qty_after_transaction = qty_after_transaction + {qty_shift}
 		where
 			item_code = %(item_code)s
 			and warehouse = %(warehouse)s
@@ -876,15 +902,70 @@
 					and creation > %(creation)s
 				)
 			)
-	""".format(qty=args.actual_qty), args)
+		{datetime_limit_condition}
+		""".format(qty_shift=qty_shift, datetime_limit_condition=datetime_limit_condition), args)
 
 	validate_negative_qty_in_future_sle(args, allow_negative_stock)
 
+def get_stock_reco_qty_shift(args):
+	stock_reco_qty_shift = 0
+	if args.get("is_cancelled"):
+		if args.get("previous_qty_after_transaction"):
+			# get qty (balance) that was set at submission
+			last_balance = args.get("previous_qty_after_transaction")
+			stock_reco_qty_shift = flt(args.qty_after_transaction) - flt(last_balance)
+		else:
+			stock_reco_qty_shift = flt(args.actual_qty)
+	else:
+		# reco is being submitted
+		last_balance = get_previous_sle_of_current_voucher(args,
+			exclude_current_voucher=True).get("qty_after_transaction")
+
+		if last_balance is not None:
+			stock_reco_qty_shift = flt(args.qty_after_transaction) - flt(last_balance)
+		else:
+			stock_reco_qty_shift = args.qty_after_transaction
+
+	return stock_reco_qty_shift
+
+def get_next_stock_reco(args):
+	"""Returns next nearest stock reconciliaton's details."""
+
+	return frappe.db.sql("""
+		select
+			name, posting_date, posting_time, creation, voucher_no
+		from
+			`tabStock Ledger Entry`
+		where
+			item_code = %(item_code)s
+			and warehouse = %(warehouse)s
+			and voucher_type = 'Stock Reconciliation'
+			and voucher_no != %(voucher_no)s
+			and is_cancelled = 0
+			and (timestamp(posting_date, posting_time) > timestamp(%(posting_date)s, %(posting_time)s)
+				or (
+					timestamp(posting_date, posting_time) = timestamp(%(posting_date)s, %(posting_time)s)
+					and creation > %(creation)s
+				)
+			)
+		limit 1
+	""", args, as_dict=1)
+
+def get_datetime_limit_condition(detail):
+	return f"""
+		and
+		(timestamp(posting_date, posting_time) < timestamp('{detail.posting_date}', '{detail.posting_time}')
+			or (
+				timestamp(posting_date, posting_time) = timestamp('{detail.posting_date}', '{detail.posting_time}')
+				and creation < '{detail.creation}'
+			)
+		)"""
+
 def validate_negative_qty_in_future_sle(args, allow_negative_stock=None):
 	allow_negative_stock = allow_negative_stock \
 		or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
 
-	if args.actual_qty < 0 and not allow_negative_stock:
+	if (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation") and not allow_negative_stock:
 		sle = get_future_sle_with_negative_qty(args)
 		if sle:
 			message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format(