Merge pull request #25471 from rohitwaghchaure/allow-to-receipt-same-serial-no

fix: allow to receive same serial numbers multiple times
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index 78f7773..e5ef978 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -13,8 +13,9 @@
 from erpnext.accounts.doctype.account.test_account import get_inventory_account
 from erpnext.stock.doctype.item.test_item import make_item
 from six import iteritems
+from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction
 from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
-
+from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
 
 class TestPurchaseReceipt(unittest.TestCase):
 	def setUp(self):
@@ -144,6 +145,62 @@
 		self.assertFalse(frappe.db.get_value('Batch', {'item': item.name, 'reference_name': pr.name}))
 		self.assertFalse(frappe.db.get_all('Serial No', {'batch_no': batch_no}))
 
+	def test_duplicate_serial_nos(self):
+		from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+
+		item = frappe.db.exists("Item", {'item_name': 'Test Serialized Item 123'})
+		if not item:
+			item = create_item("Test Serialized Item 123")
+			item.has_serial_no = 1
+			item.serial_no_series = "TSI123-.####"
+			item.save()
+		else:
+			item = frappe.get_doc("Item", {'item_name': 'Test Serialized Item 123'})
+
+		# First make purchase receipt
+		pr = make_purchase_receipt(item_code=item.name, qty=2, rate=500)
+		pr.load_from_db()
+
+		serial_nos = frappe.db.get_value('Stock Ledger Entry',
+			{'voucher_type': 'Purchase Receipt', 'voucher_no': pr.name, 'item_code': item.name}, 'serial_no')
+
+		serial_nos = get_serial_nos(serial_nos)
+
+		self.assertEquals(get_serial_nos(pr.items[0].serial_no), serial_nos)
+
+		# Then tried to receive same serial nos in difference company
+		pr_different_company = make_purchase_receipt(item_code=item.name, qty=2, rate=500,
+			serial_no='\n'.join(serial_nos), company='_Test Company 1', do_not_submit=True,
+			warehouse = 'Stores - _TC1')
+
+		self.assertRaises(SerialNoDuplicateError, pr_different_company.submit)
+
+		# Then made delivery note to remove the serial nos from stock
+		dn = create_delivery_note(item_code=item.name, qty=2, rate = 1500, serial_no='\n'.join(serial_nos))
+		dn.load_from_db()
+		self.assertEquals(get_serial_nos(dn.items[0].serial_no), serial_nos)
+
+		posting_date = add_days(today(), -3)
+
+		# Try to receive same serial nos again in the same company with backdated.
+		pr1 = make_purchase_receipt(item_code=item.name, qty=2, rate=500,
+			posting_date=posting_date, serial_no='\n'.join(serial_nos), do_not_submit=True)
+
+		self.assertRaises(SerialNoExistsInFutureTransaction, pr1.submit)
+
+		# Try to receive same serial nos with different company with backdated.
+		pr2 = make_purchase_receipt(item_code=item.name, qty=2, rate=500,
+			posting_date=posting_date, serial_no='\n'.join(serial_nos), company='_Test Company 1', do_not_submit=True,
+			warehouse = 'Stores - _TC1')
+
+		self.assertRaises(SerialNoExistsInFutureTransaction, pr2.submit)
+
+		# Receive the same serial nos after the delivery note posting date and time
+		make_purchase_receipt(item_code=item.name, qty=2, rate=500, serial_no='\n'.join(serial_nos))
+
+		# Raise the error for backdated deliver note entry cancel
+		self.assertRaises(SerialNoExistsInFutureTransaction, dn.cancel)
+
 	def test_purchase_receipt_gl_entry(self):
 		pr = make_purchase_receipt(company="_Test Company with perpetual inventory",
 			warehouse = "Stores - TCP1", supplier_warehouse = "Work in Progress - TCP1",
@@ -562,30 +619,6 @@
 
 		new_pr_doc.cancel()
 
-	def test_not_accept_duplicate_serial_no(self):
-		from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
-		from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
-
-		item_code = frappe.db.get_value('Item', {'has_serial_no': 1, 'is_fixed_asset': 0, "has_batch_no": 0})
-		if not item_code:
-			item = make_item("Test Serial Item 1", dict(has_serial_no=1, has_batch_no=0))
-			item_code = item.name
-
-		serial_no = random_string(5)
-		pr1 = make_purchase_receipt(item_code=item_code, qty=1, serial_no=serial_no)
-		dn = create_delivery_note(item_code=item_code, qty=1, serial_no=serial_no)
-
-		pr2 = make_purchase_receipt(item_code=item_code, qty=1, serial_no=serial_no, do_not_submit=True)
-		self.assertRaises(SerialNoDuplicateError, pr2.submit)
-
-		se = make_stock_entry(item_code=item_code, target="_Test Warehouse - _TC", qty=1,
-			serial_no=serial_no, basic_rate=100, do_not_submit=True)
-		se.submit()
-
-		se.cancel()
-		dn.cancel()
-		pr1.cancel()
-
 	def test_auto_asset_creation(self):
 		asset_item = "Test Asset Item"
 
diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py
index c02dd2e..5ecc9f8 100644
--- a/erpnext/stock/doctype/serial_no/serial_no.py
+++ b/erpnext/stock/doctype/serial_no/serial_no.py
@@ -243,7 +243,7 @@
 				if frappe.db.exists("Serial No", serial_no):
 					sr = frappe.db.get_value("Serial No", serial_no, ["name", "item_code", "batch_no", "sales_order",
 						"delivery_document_no", "delivery_document_type", "warehouse", "purchase_document_type",
-						"purchase_document_no", "company"], as_dict=1)
+						"purchase_document_no", "company", "status"], as_dict=1)
 
 					if sr.item_code!=sle.item_code:
 						if not allow_serial_nos_with_different_item(serial_no, sle):
@@ -266,6 +266,9 @@
 							frappe.throw(_("Serial No {0} does not belong to Warehouse {1}").format(serial_no,
 								sle.warehouse), SerialNoWarehouseError)
 
+						if not sr.purchase_document_no:
+							frappe.throw(_("Serial No {0} not in stock").format(serial_no), SerialNoNotExistsError)
+
 						if sle.voucher_type in ("Delivery Note", "Sales Invoice"):
 
 							if sr.batch_no and sr.batch_no != sle.batch_no:
@@ -382,19 +385,6 @@
 	if sn.company != sle.company:
 		return False
 
-	status = False
-	if sn.purchase_document_no:
-		if (sle.voucher_type in ['Purchase Receipt', 'Stock Entry', "Purchase Invoice"] and
-			sn.delivery_document_type not in ['Purchase Receipt', 'Stock Entry', "Purchase Invoice"]):
-			status = True
-
-		# If status is receipt then system will allow to in-ward the delivered serial no
-		if (status and sle.voucher_type == "Stock Entry" and frappe.db.get_value("Stock Entry",
-			sle.voucher_no, "purpose") in ("Material Receipt", "Material Transfer")):
-			status = False
-
-	return status
-
 def allow_serial_nos_with_different_item(sle_serial_no, sle):
 	"""
 		Allows same serial nos for raw materials and finished goods
diff --git a/erpnext/stock/report/serial_no_ledger/__init__.py b/erpnext/stock/report/serial_no_ledger/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/stock/report/serial_no_ledger/__init__.py
diff --git a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.js b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.js
new file mode 100644
index 0000000..616312e
--- /dev/null
+++ b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.js
@@ -0,0 +1,52 @@
+// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+/* eslint-disable */
+
+frappe.query_reports["Serial No Ledger"] = {
+	"filters": [
+		{
+			'label': __('Item Code'),
+			'fieldtype': 'Link',
+			'fieldname': 'item_code',
+			'reqd': 1,
+			'options': 'Item',
+			get_query: function() {
+				return {
+					filters: {
+						'has_serial_no': 1
+					}
+				}
+			}
+		},
+		{
+			'label': __('Serial No'),
+			'fieldtype': 'Link',
+			'fieldname': 'serial_no',
+			'options': 'Serial No',
+			'reqd': 1
+		},
+		{
+			'label': __('Warehouse'),
+			'fieldtype': 'Link',
+			'fieldname': 'warehouse',
+			'options': 'Warehouse',
+			get_query: function() {
+				let company = frappe.query_report.get_filter_value('company');
+
+				if (company) {
+					return {
+						filters: {
+							'company': company
+						}
+					}
+				}
+			}
+		},
+		{
+			'label': __('As On Date'),
+			'fieldtype': 'Date',
+			'fieldname': 'posting_date',
+			'default': frappe.datetime.get_today()
+		},
+	]
+};
diff --git a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.json b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.json
new file mode 100644
index 0000000..e20e74c
--- /dev/null
+++ b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.json
@@ -0,0 +1,33 @@
+{
+ "add_total_row": 0,
+ "columns": [],
+ "creation": "2021-04-20 13:32:41.523219",
+ "disable_prepared_report": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "json": "{}",
+ "modified": "2021-04-20 13:33:19.015829",
+ "modified_by": "Administrator",
+ "module": "Stock",
+ "name": "Serial No Ledger",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Stock Ledger Entry",
+ "report_name": "Serial No Ledger",
+ "report_type": "Script Report",
+ "roles": [
+  {
+   "role": "Stock User"
+  },
+  {
+   "role": "Purchase User"
+  },
+  {
+   "role": "Sales User"
+  }
+ ]
+}
\ No newline at end of file
diff --git a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py
new file mode 100644
index 0000000..c3339fd
--- /dev/null
+++ b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py
@@ -0,0 +1,53 @@
+# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from __future__ import unicode_literals
+from frappe import _
+from erpnext.stock.stock_ledger import get_stock_ledger_entries
+from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
+
+def execute(filters=None):
+	columns = get_columns(filters)
+	data = get_data(filters)
+	return columns, data
+
+def get_columns(filters):
+	columns = [{
+		'label': _('Posting Date'),
+		'fieldtype': 'Date',
+		'fieldname': 'posting_date'
+	}, {
+		'label': _('Posting Time'),
+		'fieldtype': 'Time',
+		'fieldname': 'posting_time'
+	}, {
+		'label': _('Voucher Type'),
+		'fieldtype': 'Link',
+		'fieldname': 'voucher_type',
+		'options': 'DocType',
+		'width': 220
+	}, {
+		'label': _('Voucher No'),
+		'fieldtype': 'Dynamic Link',
+		'fieldname': 'voucher_no',
+		'options': 'voucher_type',
+		'width': 220
+	}, {
+		'label': _('Company'),
+		'fieldtype': 'Link',
+		'fieldname': 'company',
+		'options': 'Company',
+		'width': 220
+	}, {
+		'label': _('Warehouse'),
+		'fieldtype': 'Link',
+		'fieldname': 'warehouse',
+		'options': 'Warehouse',
+		'width': 220
+	}]
+
+	return columns
+
+def get_data(filters):
+	return get_stock_ledger_entries(filters, '<=', order="asc") or []
+
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index bbfcb7a..9729987 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -2,9 +2,11 @@
 # License: GNU General Public License v3. See license.txt
 from __future__ import unicode_literals
 
-import frappe, erpnext
+import frappe
+import erpnext
+import copy
 from frappe import _
-from frappe.utils import cint, flt, cstr, now, now_datetime
+from frappe.utils import cint, flt, cstr, now, get_link_to_form
 from frappe.model.meta import get_field_precision
 from erpnext.stock.utils import get_valuation_method, get_incoming_outgoing_rate_for_cancel
 from erpnext.stock.utils import get_bin
@@ -13,6 +15,8 @@
 
 # future reposting
 class NegativeStockError(frappe.ValidationError): pass
+class SerialNoExistsInFutureTransaction(frappe.ValidationError):
+	pass
 
 _exceptions = frappe.local('stockledger_exceptions')
 # _exceptions = []
@@ -27,6 +31,9 @@
 			set_as_cancel(sl_entries[0].get('voucher_type'), sl_entries[0].get('voucher_no'))
 
 		for sle in sl_entries:
+			if sle.serial_no:
+				validate_serial_no(sle)
+
 			if cancel:
 				sle['actual_qty'] = -flt(sle.get('actual_qty'))
 
@@ -46,6 +53,30 @@
 			args = sle_doc.as_dict()
 			update_bin(args, allow_negative_stock, via_landed_cost_voucher)
 
+def validate_serial_no(sle):
+	from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
+	for sn in get_serial_nos(sle.serial_no):
+		args = copy.deepcopy(sle)
+		args.serial_no = sn
+		args.warehouse = ''
+
+		vouchers = []
+		for row in get_stock_ledger_entries(args, '>'):
+			voucher_type = frappe.bold(row.voucher_type)
+			voucher_no = frappe.bold(get_link_to_form(row.voucher_type, row.voucher_no))
+			vouchers.append(f'{voucher_type} {voucher_no}')
+
+		if vouchers:
+			serial_no = frappe.bold(sn)
+			msg = (f'''The serial no {serial_no} has been used in the future transactions so you need to cancel them first.
+				The list of the transactions are as below.''' + '<br><br><ul><li>')
+
+			msg += '</li><li>'.join(vouchers)
+			msg += '</li></ul>'
+
+			title = 'Cannot Submit' if not sle.get('is_cancelled') else 'Cannot Cancel'
+			frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransaction)
+
 def validate_cancellation(args):
 	if args[0].get("is_cancelled"):
 		repost_entry = frappe.db.get_value("Repost Item Valuation", {
@@ -718,7 +749,17 @@
 		conditions += " and " + previous_sle.get("warehouse_condition")
 
 	if check_serial_no and previous_sle.get("serial_no"):
-		conditions += " and serial_no like {}".format(frappe.db.escape('%{0}%'.format(previous_sle.get("serial_no"))))
+		# conditions += " and serial_no like {}".format(frappe.db.escape('%{0}%'.format(previous_sle.get("serial_no"))))
+		serial_no = previous_sle.get("serial_no")
+		conditions += (""" and
+			(
+				serial_no = {0}
+				or serial_no like {1}
+				or serial_no like {2}
+				or serial_no like {3}
+			)
+		""").format(frappe.db.escape(serial_no), frappe.db.escape('{}\n%'.format(serial_no)),
+			frappe.db.escape('%\n{}'.format(serial_no)), frappe.db.escape('%\n{}\n%'.format(serial_no)))
 
 	if not previous_sle.get("posting_date"):
 		previous_sle["posting_date"] = "1900-01-01"
@@ -793,12 +834,12 @@
 	if not allow_zero_rate and not valuation_rate and raise_error_if_no_rate \
 			and cint(erpnext.is_perpetual_inventory_enabled(company)):
 		frappe.local.message_log = []
-		form_link = frappe.utils.get_link_to_form("Item", item_code)
+		form_link = get_link_to_form("Item", item_code)
 
 		message = _("Valuation Rate for the Item {0}, is required to do accounting entries for {1} {2}.").format(form_link, voucher_type, voucher_no)
-		message += "<br><br>" + _(" Here are the options to proceed:")
+		message += "<br><br>" + _("Here are the options to proceed:")
 		solutions = "<li>" + _("If the item is transacting as a Zero Valuation Rate item in this entry, please enable 'Allow Zero Valuation Rate' in the {0} Item table.").format(voucher_type) + "</li>"
-		solutions += "<li>" + _("If not, you can Cancel / Submit this entry ") + _("{0}").format(frappe.bold("after")) + _(" performing either one below:") + "</li>"
+		solutions += "<li>" + _("If not, you can Cancel / Submit this entry") + " {0} ".format(frappe.bold("after")) + _("performing either one below:") + "</li>"
 		sub_solutions = "<ul><li>" + _("Create an incoming stock transaction for the Item.") + "</li>"
 		sub_solutions += "<li>" + _("Mention Valuation Rate in the Item master.") + "</li></ul>"
 		msg = message + solutions + sub_solutions + "</li>"