Merge pull request #28776 from rtdany10/ksa-vat-updates

feat(Regional): KSA E-Invoing optimizations and POS support
diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js
index 7f98354..6f6ca61 100644
--- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js
+++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js
@@ -43,14 +43,11 @@
 				}
 			});
 		}
-		else {
-			frm.clear_table("purposes");
-		}
-
 		if (!frm.doc.status) {
 			frm.set_value({ status: 'Draft' });
 		}
 		if (frm.doc.__islocal) {
+			frm.clear_table("purposes");
 			frm.set_value({ mntc_date: frappe.datetime.get_today() });
 		}
 	},
diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py
index 9743c3b..fd3ec3c 100644
--- a/erpnext/regional/india/utils.py
+++ b/erpnext/regional/india/utils.py
@@ -213,7 +213,7 @@
 		tax_template_by_category = get_tax_template_based_on_category(master_doctype, company, party_details)
 
 	if tax_template_by_category:
-		party_details.get['taxes_and_charges'] = tax_template_by_category
+		party_details['taxes_and_charges'] = tax_template_by_category
 		return
 
 	if not party_details.place_of_supply: return party_details
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index 5ef0770..5a9e77e 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -24,7 +24,8 @@
 from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
 	create_stock_reconciliation,
 )
-from erpnext.stock.stock_ledger import get_previous_sle
+from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle
+from erpnext.tests.utils import ERPNextTestCase, change_settings
 
 
 def get_sle(**args):
@@ -38,7 +39,7 @@
 		order by timestamp(posting_date, posting_time) desc, creation desc limit 1"""% condition,
 		values, as_dict=1)
 
-class TestStockEntry(unittest.TestCase):
+class TestStockEntry(ERPNextTestCase):
 	def tearDown(self):
 		frappe.set_user("Administrator")
 		frappe.db.set_value("Manufacturing Settings", None, "material_consumption", "0")
@@ -928,6 +929,83 @@
 		distributed_costs = [d.additional_cost for d in se.items]
 		self.assertEqual([40.0, 60.0], distributed_costs)
 
+	@change_settings("Stock Settings", {"allow_negative_stock": 0})
+	def test_future_negative_sle(self):
+		# Initialize item, batch, warehouse, opening qty
+		item_code = '_Test Future Neg Item'
+		batch_no = '_Test Future Neg Batch'
+		warehouses = [
+			'_Test Future Neg Warehouse Source',
+			'_Test Future Neg Warehouse Destination'
+		]
+		warehouse_names = initialize_records_for_future_negative_sle_test(
+			item_code, batch_no, warehouses,
+			opening_qty=2, posting_date='2021-07-01'
+		)
+
+		# Executing an illegal sequence should raise an error
+		sequence_of_entries = [
+			dict(item_code=item_code,
+				qty=2,
+				from_warehouse=warehouse_names[0],
+				to_warehouse=warehouse_names[1],
+				batch_no=batch_no,
+				posting_date='2021-07-03',
+				purpose='Material Transfer'),
+			dict(item_code=item_code,
+				qty=2,
+				from_warehouse=warehouse_names[1],
+				to_warehouse=warehouse_names[0],
+				batch_no=batch_no,
+				posting_date='2021-07-04',
+				purpose='Material Transfer'),
+			dict(item_code=item_code,
+				qty=2,
+				from_warehouse=warehouse_names[0],
+				to_warehouse=warehouse_names[1],
+				batch_no=batch_no,
+				posting_date='2021-07-02',          # Illegal SE
+				purpose='Material Transfer')
+		]
+
+		self.assertRaises(NegativeStockError, create_stock_entries, sequence_of_entries)
+
+	@change_settings("Stock Settings", {"allow_negative_stock": 0})
+	def test_future_negative_sle_batch(self):
+		from erpnext.stock.doctype.batch.test_batch import TestBatch
+
+		# Initialize item, batch, warehouse, opening qty
+		item_code = '_Test MultiBatch Item'
+		TestBatch.make_batch_item(item_code)
+
+		batch_nos = [] # store generate batches
+		warehouse = '_Test Warehouse - _TC'
+
+		se1 = make_stock_entry(
+				item_code=item_code,
+				qty=2,
+				to_warehouse=warehouse,
+				posting_date='2021-09-01',
+				purpose='Material Receipt'
+			)
+		batch_nos.append(se1.items[0].batch_no)
+		se2 = make_stock_entry(
+				item_code=item_code,
+				qty=2,
+				to_warehouse=warehouse,
+				posting_date='2021-09-03',
+				purpose='Material Receipt'
+			)
+		batch_nos.append(se2.items[0].batch_no)
+
+		with self.assertRaises(NegativeStockError) as nse:
+			make_stock_entry(item_code=item_code,
+				qty=1,
+				from_warehouse=warehouse,
+				batch_no=batch_nos[1],
+				posting_date='2021-09-02', # backdated consumption of 2nd batch
+				purpose='Material Issue')
+
 def make_serialized_item(**args):
 	args = frappe._dict(args)
 	se = frappe.copy_doc(test_records[0])
@@ -998,3 +1076,31 @@
 		]
 
 test_records = frappe.get_test_records('Stock Entry')
+
+def initialize_records_for_future_negative_sle_test(
+		item_code, batch_no, warehouses, opening_qty, posting_date):
+	from erpnext.stock.doctype.batch.test_batch import TestBatch, make_new_batch
+	from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
+		create_stock_reconciliation,
+	)
+	from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
+
+	TestBatch.make_batch_item(item_code)
+	make_new_batch(item_code=item_code, batch_id=batch_no)
+	warehouse_names = [create_warehouse(w) for w in warehouses]
+	create_stock_reconciliation(
+		purpose='Opening Stock',
+		posting_date=posting_date,
+		posting_time='20:00:20',
+		item_code=item_code,
+		warehouse=warehouse_names[0],
+		valuation_rate=100,
+		qty=opening_qty,
+		batch_no=batch_no,
+	)
+	return warehouse_names
+
+
+def create_stock_entries(sequence_of_entries):
+	for entry_detail in sequence_of_entries:
+		make_stock_entry(**entry_detail)
diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
index 93bca7a..c538307 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
@@ -8,7 +8,7 @@
 from frappe import _
 from frappe.core.doctype.role.role import get_users
 from frappe.model.document import Document
-from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate
+from frappe.utils import add_days, cint, formatdate, get_datetime, getdate
 
 from erpnext.accounts.utils import get_fiscal_year
 from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock
@@ -43,7 +43,6 @@
 
 	def on_submit(self):
 		self.check_stock_frozen_date()
-		self.actual_amt_check()
 		self.calculate_batch_qty()
 
 		if not self.get("via_landed_cost_voucher"):
@@ -57,18 +56,6 @@
 				"sum(actual_qty)") or 0
 			frappe.db.set_value("Batch", self.batch_no, "batch_qty", batch_qty)
 
-	def actual_amt_check(self):
-		"""Validate that qty at warehouse for selected batch is >=0"""
-		if self.batch_no and not self.get("allow_negative_stock"):
-			batch_bal_after_transaction = flt(frappe.db.sql("""select sum(actual_qty)
-				from `tabStock Ledger Entry`
-				where is_cancelled =0 and warehouse=%s and item_code=%s and batch_no=%s""",
-				(self.warehouse, self.item_code, self.batch_no))[0][0])
-
-			if batch_bal_after_transaction < 0:
-				frappe.throw(_("Stock balance in Batch {0} will become negative {1} for Item {2} at Warehouse {3}")
-					.format(self.batch_no, batch_bal_after_transaction, self.item_code, self.warehouse))
-
 	def validate_mandatory(self):
 		mandatory = ['warehouse','posting_date','voucher_type','voucher_no','company']
 		for k in mandatory:
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index d78632a..e95c0fc 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -1089,17 +1089,36 @@
 	allow_negative_stock = cint(allow_negative_stock) \
 		or cint(frappe.db.get_single_value("Stock Settings", "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(
-				abs(sle[0]["qty_after_transaction"]),
-				frappe.get_desk_link('Item', args.item_code),
-				frappe.get_desk_link('Warehouse', args.warehouse),
-				sle[0]["posting_date"], sle[0]["posting_time"],
-				frappe.get_desk_link(sle[0]["voucher_type"], sle[0]["voucher_no"]))
+	if allow_negative_stock:
+		return
+	if not (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation"):
+		return
 
-			frappe.throw(message, NegativeStockError, title='Insufficient Stock')
+	neg_sle = get_future_sle_with_negative_qty(args)
+	if neg_sle:
+		message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format(
+			abs(neg_sle[0]["qty_after_transaction"]),
+			frappe.get_desk_link('Item', args.item_code),
+			frappe.get_desk_link('Warehouse', args.warehouse),
+			neg_sle[0]["posting_date"], neg_sle[0]["posting_time"],
+			frappe.get_desk_link(neg_sle[0]["voucher_type"], neg_sle[0]["voucher_no"]))
+
+		frappe.throw(message, NegativeStockError, title='Insufficient Stock')
+
+
+	if not args.batch_no:
+		return
+
+	neg_batch_sle = get_future_sle_with_negative_batch_qty(args)
+	if neg_batch_sle:
+		message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format(
+			abs(neg_batch_sle[0]["cumulative_total"]),
+			frappe.get_desk_link('Batch', args.batch_no),
+			frappe.get_desk_link('Warehouse', args.warehouse),
+			neg_batch_sle[0]["posting_date"], neg_batch_sle[0]["posting_time"],
+			frappe.get_desk_link(neg_batch_sle[0]["voucher_type"], neg_batch_sle[0]["voucher_no"]))
+		frappe.throw(message, NegativeStockError, title="Insufficient Stock for Batch")
+
 
 def get_future_sle_with_negative_qty(args):
 	return frappe.db.sql("""
@@ -1118,6 +1137,29 @@
 		limit 1
 	""", args, as_dict=1)
 
+
+def get_future_sle_with_negative_batch_qty(args):
+	return frappe.db.sql("""
+		with batch_ledger as (
+			select
+				posting_date, posting_time, voucher_type, voucher_no,
+				sum(actual_qty) over (order by posting_date, posting_time, creation) as cumulative_total
+			from `tabStock Ledger Entry`
+			where
+				item_code = %(item_code)s
+				and warehouse = %(warehouse)s
+				and batch_no=%(batch_no)s
+				and is_cancelled = 0
+			order by posting_date, posting_time, creation
+		)
+		select * from batch_ledger
+		where
+			cumulative_total < 0.0
+			and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s)
+		limit 1
+	""", args, as_dict=1)
+
+
 def _round_off_if_near_zero(number: float, precision: int = 6) -> float:
 	""" Rounds off the number to zero only if number is close to zero for decimal
 		specified in precision. Precision defaults to 6.