Merge branch 'develop' into pos-batch-no-stock-validation
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index 0d6404c..6cda626 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -15,6 +15,7 @@
 	update_multi_mode_option,
 )
 from erpnext.accounts.party import get_due_date, get_party_account
+from erpnext.stock.doctype.batch.batch import get_pos_reserved_batch_qty
 from erpnext.stock.doctype.serial_no.serial_no import get_pos_reserved_serial_nos, get_serial_nos
 
 
@@ -124,9 +125,26 @@
 			frappe.throw(_("Row #{}: Serial No. {} has already been transacted into another POS Invoice. Please select valid serial no.")
 						.format(item.idx, bold_invalid_serial_nos), title=_("Item Unavailable"))
 		elif invalid_serial_nos:
-			frappe.throw(_("Row #{}: Serial Nos. {} has already been transacted into another POS Invoice. Please select valid serial no.")
+			frappe.throw(_("Row #{}: Serial Nos. {} have already been transacted into another POS Invoice. Please select valid serial no.")
 						.format(item.idx, bold_invalid_serial_nos), title=_("Item Unavailable"))
 
+	def validate_pos_reserved_batch_qty(self, item):
+		filters = {"item_code": item.item_code, "warehouse": item.warehouse, "batch_no":item.batch_no}
+
+		available_batch_qty = frappe.db.get_value('Batch', item.batch_no, 'batch_qty')
+		reserved_batch_qty = get_pos_reserved_batch_qty(filters)
+
+		bold_item_name = frappe.bold(item.item_name)
+		bold_extra_batch_qty_needed = frappe.bold(abs(available_batch_qty - reserved_batch_qty - item.qty))
+		bold_invalid_batch_no = frappe.bold(item.batch_no)
+
+		if (available_batch_qty - reserved_batch_qty) == 0:
+			frappe.throw(_("Row #{}: Batch No. {} of item {} has no stock available. Please select valid batch no.")
+						.format(item.idx, bold_invalid_batch_no, bold_item_name), title=_("Item Unavailable"))
+		elif (available_batch_qty - reserved_batch_qty - item.qty) < 0:
+			frappe.throw(_("Row #{}: Batch No. {} of item {} has less than required stock available, {} more required")
+						.format(item.idx, bold_invalid_batch_no, bold_item_name, bold_extra_batch_qty_needed), title=_("Item Unavailable"))
+
 	def validate_delivered_serial_nos(self, item):
 		serial_nos = get_serial_nos(item.serial_no)
 		delivered_serial_nos = frappe.db.get_list('Serial No', {
@@ -149,6 +167,8 @@
 			if d.serial_no:
 				self.validate_pos_reserved_serial_nos(d)
 				self.validate_delivered_serial_nos(d)
+			elif d.batch_no:
+				self.validate_pos_reserved_batch_qty(d)
 			else:
 				if allow_negative_stock:
 					return
diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
index 6696333..7d31e0a 100644
--- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
@@ -521,6 +521,41 @@
 		rounded_total = frappe.db.get_value("Sales Invoice", pos_inv2.consolidated_invoice, "rounded_total")
 		self.assertEqual(rounded_total, 400)
 
+	def test_pos_batch_item_qty_validation(self):
+		from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
+			create_batch_item_with_batch,
+		)
+		create_batch_item_with_batch('_BATCH ITEM', 'TestBatch 01')
+		item = frappe.get_doc('Item', '_BATCH ITEM')
+		batch = frappe.get_doc('Batch', 'TestBatch 01')
+		batch.submit()
+		item.batch_no = 'TestBatch 01'
+		item.save()
+
+		se = make_stock_entry(target="_Test Warehouse - _TC", item_code="_BATCH ITEM", qty=2, basic_rate=100, batch_no='TestBatch 01')
+
+		pos_inv1 = create_pos_invoice(item=item.name, rate=300, qty=1, do_not_submit=1)
+		pos_inv1.items[0].batch_no = 'TestBatch 01'
+		pos_inv1.save()
+		pos_inv1.submit()
+
+		pos_inv2 = create_pos_invoice(item=item.name, rate=300, qty=2, do_not_submit=1)
+		pos_inv2.items[0].batch_no = 'TestBatch 01'
+		pos_inv2.save()
+
+		self.assertRaises(frappe.ValidationError, pos_inv2.submit)
+
+		#teardown
+		pos_inv1.reload()
+		pos_inv1.cancel()
+		pos_inv1.delete()
+		pos_inv2.reload()
+		pos_inv2.delete()
+		se.cancel()
+		batch.reload()
+		batch.cancel()
+		batch.delete()
+
 def create_pos_invoice(**args):
 	args = frappe._dict(args)
 	pos_profile = None
@@ -557,7 +592,8 @@
 		"income_account": args.income_account or "Sales - _TC",
 		"expense_account": args.expense_account or "Cost of Goods Sold - _TC",
 		"cost_center": args.cost_center or "_Test Cost Center - _TC",
-		"serial_no": args.serial_no
+		"serial_no": args.serial_no,
+		"batch_no": args.batch_no
 	})
 
 	if not args.do_not_save:
@@ -570,3 +606,8 @@
 		pos_inv.payment_schedule = []
 
 	return pos_inv
+
+def make_batch_item(item_name):
+	from erpnext.stock.doctype.item.test_item import make_item
+	if not frappe.db.exists(item_name):
+		return make_item(item_name, dict(has_batch_no = 1, create_new_batch = 1, is_stock_item=1))
\ No newline at end of file
diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py
index fdefd24..ebca87e 100644
--- a/erpnext/stock/doctype/batch/batch.py
+++ b/erpnext/stock/doctype/batch/batch.py
@@ -312,3 +312,30 @@
 	if frappe.db.get_value("Item", args.item, "has_batch_no"):
 		args.doctype = "Batch"
 		frappe.get_doc(args).insert().name
+
+@frappe.whitelist()
+def get_pos_reserved_batch_qty(filters):
+	import json
+
+	if isinstance(filters, str):
+		filters = json.loads(filters)
+
+	p = frappe.qb.DocType("POS Invoice").as_("p")
+	item = frappe.qb.DocType("POS Invoice Item").as_("item")
+
+	pos_transacted_batch_nos = frappe.qb.from_(p).from_(item).select(item.qty).where(
+		(p.name == item.parent) &
+		(p.consolidated_invoice.isnull()) &
+		(p.status != "Consolidated") &
+		(p.docstatus == 1) &
+		(item.docstatus == 1) &
+		(item.item_code == filters.get('item_code')) &
+		(item.warehouse == filters.get('warehouse')) &
+		(item.batch_no == filters.get('batch_no'))
+	).run(as_dict=True)
+
+	reserved_batch_qty = 0.0
+	for d in pos_transacted_batch_nos:
+		reserved_batch_qty += d.qty
+
+	return reserved_batch_qty
\ No newline at end of file