fix: serial / batch barcode scanner (#39114)

diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 0409c68..82b5416 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -129,6 +129,17 @@
 			if self.doctype in relevant_docs:
 				self.set_payment_schedule()
 
+	def remove_bundle_for_non_stock_invoices(self):
+		has_sabb = False
+		if self.doctype in ("Sales Invoice", "Purchase Invoice") and not self.update_stock:
+			for item in self.get("items"):
+				if item.serial_and_batch_bundle:
+					item.serial_and_batch_bundle = None
+					has_sabb = True
+
+		if has_sabb:
+			self.remove_serial_and_batch_bundle()
+
 	def ensure_supplier_is_not_blocked(self):
 		is_supplier_payment = self.doctype == "Payment Entry" and self.party_type == "Supplier"
 		is_buying_invoice = self.doctype in ["Purchase Invoice", "Purchase Order"]
@@ -156,6 +167,9 @@
 		if self.get("_action") and self._action != "update_after_submit":
 			self.set_missing_values(for_validate=True)
 
+		if self.get("_action") == "submit":
+			self.remove_bundle_for_non_stock_invoices()
+
 		self.ensure_supplier_is_not_blocked()
 
 		self.validate_date_with_fiscal_year()
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index 9427c38..4d8f683 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -454,7 +454,7 @@
 		item.weight_uom = '';
 		item.conversion_factor = 0;
 
-		if(['Sales Invoice'].includes(this.frm.doc.doctype)) {
+		if(['Sales Invoice', 'Purchase Invoice'].includes(this.frm.doc.doctype)) {
 			update_stock = cint(me.frm.doc.update_stock);
 			show_batch_dialog = update_stock;
 
@@ -545,7 +545,7 @@
 								},
 								() => me.toggle_conversion_factor(item),
 								() => {
-									if (show_batch_dialog)
+									if (show_batch_dialog && !frappe.flags.trigger_from_barcode_scanner)
 										return frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"])
 											.then((r) => {
 												if (r.message &&
@@ -1239,6 +1239,20 @@
 		}
 	}
 
+	sync_bundle_data() {
+		let doctypes = ["Sales Invoice", "Purchase Invoice", "Delivery Note", "Purchase Receipt"];
+
+		if (this.frm.is_new() && doctypes.includes(this.frm.doc.doctype)) {
+			const barcode_scanner = new erpnext.utils.BarcodeScanner({frm:this.frm});
+			barcode_scanner.sync_bundle_data();
+			barcode_scanner.remove_item_from_localstorage();
+		}
+	}
+
+	before_save(doc) {
+		this.sync_bundle_data();
+	}
+
 	service_start_date(frm, cdt, cdn) {
 		var child = locals[cdt][cdn];
 
@@ -1576,6 +1590,18 @@
 		return item_list;
 	}
 
+	items_delete() {
+		this.update_localstorage_scanned_data();
+	}
+
+	update_localstorage_scanned_data() {
+		let doctypes = ["Sales Invoice", "Purchase Invoice", "Delivery Note", "Purchase Receipt"];
+		if (this.frm.is_new() && doctypes.includes(this.frm.doc.doctype)) {
+			const barcode_scanner = new erpnext.utils.BarcodeScanner({frm:this.frm});
+			barcode_scanner.update_localstorage_scanned_data();
+		}
+	}
+
 	_set_values_for_item_list(children) {
 		const items_rule_dict = {};
 
diff --git a/erpnext/public/js/utils/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js
index a1ebfe9..cf7fab8 100644
--- a/erpnext/public/js/utils/barcode_scanner.js
+++ b/erpnext/public/js/utils/barcode_scanner.js
@@ -7,8 +7,6 @@
 		this.scan_barcode_field = this.frm.fields_dict[this.scan_field_name];
 
 		this.barcode_field = opts.barcode_field || "barcode";
-		this.serial_no_field = opts.serial_no_field || "serial_no";
-		this.batch_no_field = opts.batch_no_field || "batch_no";
 		this.uom_field = opts.uom_field || "uom";
 		this.qty_field = opts.qty_field || "qty";
 		// field name on row which defines max quantity to be scanned e.g. picklist
@@ -84,6 +82,7 @@
 	update_table(data) {
 		return new Promise((resolve, reject) => {
 			let cur_grid = this.frm.fields_dict[this.items_table_name].grid;
+			frappe.flags.trigger_from_barcode_scanner = true;
 
 			const {item_code, barcode, batch_no, serial_no, uom} = data;
 
@@ -106,50 +105,38 @@
 				this.frm.has_items = false;
 			}
 
-			if (this.is_duplicate_serial_no(row, serial_no)) {
+			if (serial_no && this.is_duplicate_serial_no(row, item_code, serial_no)) {
 				this.clean_up();
 				reject();
 				return;
 			}
 
 			frappe.run_serially([
-				() => this.set_selector_trigger_flag(data),
-				() => this.set_serial_no(row, serial_no),
-				() => this.set_batch_no(row, batch_no),
+				() => this.set_serial_and_batch(row, item_code, serial_no, batch_no),
 				() => this.set_barcode(row, barcode),
 				() => this.set_item(row, item_code, barcode, batch_no, serial_no).then(qty => {
 					this.show_scan_message(row.idx, row.item_code, qty);
 				}),
 				() => this.set_barcode_uom(row, uom),
 				() => this.clean_up(),
-				() => this.revert_selector_flag(),
-				() => resolve(row)
+				() => resolve(row),
+				() => {
+					if (row.serial_and_batch_bundle && !this.frm.is_new()) {
+						this.frm.save();
+					}
+
+					frappe.flags.trigger_from_barcode_scanner = false;
+				}
 			]);
 		});
 	}
 
-	// batch and serial selector is reduandant when all info can be added by scan
-	// this flag on item row is used by transaction.js to avoid triggering selector
-	set_selector_trigger_flag(data) {
-		const {has_batch_no, has_serial_no} = data;
-
-		const require_selecting_batch = has_batch_no;
-		const require_selecting_serial = has_serial_no;
-
-		if (!(require_selecting_batch || require_selecting_serial)) {
-			frappe.flags.hide_serial_batch_dialog = true;
-		}
-	}
-
-	revert_selector_flag() {
-		frappe.flags.hide_serial_batch_dialog = false;
-	}
-
 	set_item(row, item_code, barcode, batch_no, serial_no) {
 		return new Promise(resolve => {
 			const increment = async (value = 1) => {
 				const item_data = {item_code: item_code};
 				item_data[this.qty_field] = Number((row[this.qty_field] || 0)) + Number(value);
+				frappe.flags.trigger_from_barcode_scanner = true;
 				await frappe.model.set_value(row.doctype, row.name, item_data);
 				return value;
 			};
@@ -158,8 +145,6 @@
 				frappe.prompt(__("Please enter quantity for item {0}", [item_code]), ({value}) => {
 					increment(value).then((value) => resolve(value));
 				});
-			} else if (this.frm.has_items) {
-				this.prepare_item_for_scan(row, item_code, barcode, batch_no, serial_no);
 			} else {
 				increment().then((value) => resolve(value));
 			}
@@ -182,9 +167,8 @@
 			frappe.model.set_value(row.doctype, row.name, item_data);
 
 			frappe.run_serially([
-				() => this.set_batch_no(row, this.dialog.get_value("batch_no")),
 				() => this.set_barcode(row, this.dialog.get_value("barcode")),
-				() => this.set_serial_no(row, this.dialog.get_value("serial_no")),
+				() => this.set_serial_and_batch(row, item_code, this.dialog.get_value("serial_no"), this.dialog.get_value("batch_no")),
 				() => this.add_child_for_remaining_qty(row),
 				() => this.clean_up()
 			]);
@@ -338,32 +322,144 @@
 		}
 	}
 
-	async set_serial_no(row, serial_no) {
-		if (serial_no && frappe.meta.has_field(row.doctype, this.serial_no_field)) {
-			const existing_serial_nos = row[this.serial_no_field];
-			let new_serial_nos = "";
-
-			if (!!existing_serial_nos) {
-				new_serial_nos = existing_serial_nos + "\n" + serial_no;
-			} else {
-				new_serial_nos = serial_no;
-			}
-			await frappe.model.set_value(row.doctype, row.name, this.serial_no_field, new_serial_nos);
+	async set_serial_and_batch(row, item_code, serial_no, batch_no) {
+		if (this.frm.is_new() || !row.serial_and_batch_bundle) {
+			this.set_bundle_in_localstorage(row, item_code, serial_no, batch_no);
+		} else if(row.serial_and_batch_bundle) {
+			frappe.call({
+				method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.update_serial_or_batch",
+				args: {
+					bundle_id: row.serial_and_batch_bundle,
+					serial_no: serial_no,
+					batch_no: batch_no,
+				},
+			})
 		}
 	}
 
+	get_key_for_localstorage() {
+		let parts = this.frm.doc.name.split("-");
+		return parts[parts.length - 1] + this.frm.doc.doctype;
+	}
+
+	update_localstorage_scanned_data() {
+		let docname = this.frm.doc.name
+		if (localStorage[docname]) {
+			let items = JSON.parse(localStorage[docname]);
+			let existing_items = this.frm.doc.items.map(d => d.item_code);
+			if (!existing_items.length) {
+				localStorage.removeItem(docname);
+				return;
+			}
+
+			for (let item_code in items) {
+				if (!existing_items.includes(item_code)) {
+					delete items[item_code];
+				}
+			}
+
+			localStorage[docname] = JSON.stringify(items);
+		}
+	}
+
+	async set_bundle_in_localstorage(row, item_code, serial_no, batch_no) {
+		let docname = this.frm.doc.name
+
+		let entries = JSON.parse(localStorage.getItem(docname));
+		if (!entries) {
+			entries = {};
+		}
+
+		let key = item_code;
+		if (!entries[key]) {
+			entries[key] = [];
+		}
+
+		let existing_row = [];
+		if (!serial_no && batch_no) {
+			existing_row = entries[key].filter((e) => e.batch_no === batch_no);
+			if (existing_row.length) {
+				existing_row[0].qty += 1;
+			}
+		} else if (serial_no) {
+			existing_row = entries[key].filter((e) => e.serial_no === serial_no);
+			if (existing_row.length) {
+				frappe.throw(__("Serial No {0} has already scanned.", [serial_no]));
+			}
+		}
+
+		if (!existing_row.length) {
+			entries[key].push({
+				"serial_no": serial_no,
+				"batch_no": batch_no,
+				"qty": 1
+			});
+		}
+
+		localStorage.setItem(docname, JSON.stringify(entries));
+
+		// Auto remove from localstorage after 1 hour
+		setTimeout(() => {
+			localStorage.removeItem(docname);
+		}, 3600000)
+	}
+
+	remove_item_from_localstorage() {
+		let docname = this.frm.doc.name;
+		if (localStorage[docname]) {
+			localStorage.removeItem(docname);
+		}
+	}
+
+	async sync_bundle_data() {
+		let docname = this.frm.doc.name;
+
+		if (localStorage[docname]) {
+			let entries = JSON.parse(localStorage[docname]);
+			if (entries) {
+				for (let entry in entries) {
+					let row = this.frm.doc.items.filter((item) => {
+						if (item.item_code === entry) {
+							return true;
+						}
+					})[0];
+
+					if (row) {
+						this.create_serial_and_batch_bundle(row, entries, entry)
+							.then(() => {
+								if (!entries) {
+									localStorage.removeItem(docname);
+								}
+							});
+					}
+				}
+			}
+		}
+	}
+
+	async create_serial_and_batch_bundle(row, entries, key) {
+		frappe.call({
+			method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.add_serial_batch_ledgers",
+			args: {
+				entries: entries[key],
+				child_row: row,
+				doc: this.frm.doc,
+				warehouse: row.warehouse,
+				do_not_save: 1
+			},
+			callback: function(r) {
+				row.serial_and_batch_bundle = r.message.name;
+				delete entries[key];
+			}
+		})
+	}
+
 	async set_barcode_uom(row, uom) {
 		if (uom && frappe.meta.has_field(row.doctype, this.uom_field)) {
 			await frappe.model.set_value(row.doctype, row.name, this.uom_field, uom);
 		}
 	}
 
-	async set_batch_no(row, batch_no) {
-		if (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) {
-			await frappe.model.set_value(row.doctype, row.name, this.batch_no_field, batch_no);
-		}
-	}
-
 	async set_barcode(row, barcode) {
 		if (barcode && frappe.meta.has_field(row.doctype, this.barcode_field)) {
 			await frappe.model.set_value(row.doctype, row.name, this.barcode_field, barcode);
@@ -379,13 +475,52 @@
 		}
 	}
 
-	is_duplicate_serial_no(row, serial_no) {
-		const is_duplicate = row[this.serial_no_field]?.includes(serial_no);
+	is_duplicate_serial_no(row, item_code, serial_no) {
+		if (this.frm.is_new() || !row.serial_and_batch_bundle) {
+			let is_duplicate = this.check_duplicate_serial_no_in_localstorage(item_code, serial_no);
+			if (is_duplicate) {
+				this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange");
+			}
 
-		if (is_duplicate) {
-			this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange");
+			return is_duplicate;
+		} else if (row.serial_and_batch_bundle) {
+			this.check_duplicate_serial_no_in_db(row, serial_no, (r) => {
+				if (r.message) {
+					this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange");
+				}
+
+				return r.message;
+			})
 		}
-		return is_duplicate;
+	}
+
+	async check_duplicate_serial_no_in_db(row, serial_no, response) {
+		frappe.call({
+			method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.is_duplicate_serial_no",
+			args: {
+				serial_no: serial_no,
+				bundle_id: row.serial_and_batch_bundle
+			},
+			callback(r) {
+				response(r);
+			}
+		})
+	}
+
+	check_duplicate_serial_no_in_localstorage(item_code, serial_no) {
+		let docname = this.frm.doc.name
+		let entries = JSON.parse(localStorage.getItem(docname));
+
+		if (!entries) {
+			return false;
+		}
+
+		let existing_row = [];
+		if (entries[item_code]) {
+			existing_row = entries[item_code].filter((e) => e.serial_no === serial_no);
+		}
+
+		return existing_row.length;
 	}
 
 	get_row_to_modify_on_scan(item_code, batch_no, uom, barcode) {
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
index 218406f..eede928 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
@@ -729,19 +729,13 @@
 
 	def before_cancel(self):
 		self.delink_serial_and_batch_bundle()
-		self.clear_table()
 
 	def delink_serial_and_batch_bundle(self):
-		self.voucher_no = None
-
 		sles = frappe.get_all("Stock Ledger Entry", filters={"serial_and_batch_bundle": self.name})
 
 		for sle in sles:
 			frappe.db.set_value("Stock Ledger Entry", sle.name, "serial_and_batch_bundle", None)
 
-	def clear_table(self):
-		self.set("entries", [])
-
 	@property
 	def child_table(self):
 		if self.voucher_type == "Job Card":
@@ -876,7 +870,6 @@
 		self.validate_voucher_no_docstatus()
 		self.delink_refernce_from_voucher()
 		self.delink_reference_from_batch()
-		self.clear_table()
 
 	@frappe.whitelist()
 	def add_serial_batch(self, data):
@@ -1156,7 +1149,7 @@
 
 
 @frappe.whitelist()
-def add_serial_batch_ledgers(entries, child_row, doc, warehouse) -> object:
+def add_serial_batch_ledgers(entries, child_row, doc, warehouse, do_not_save=False) -> object:
 	if isinstance(child_row, str):
 		child_row = frappe._dict(parse_json(child_row))
 
@@ -1170,20 +1163,23 @@
 	if frappe.db.exists("Serial and Batch Bundle", child_row.serial_and_batch_bundle):
 		sb_doc = update_serial_batch_no_ledgers(entries, child_row, parent_doc, warehouse)
 	else:
-		sb_doc = create_serial_batch_no_ledgers(entries, child_row, parent_doc, warehouse)
+		sb_doc = create_serial_batch_no_ledgers(
+			entries, child_row, parent_doc, warehouse, do_not_save=do_not_save
+		)
 
 	return sb_doc
 
 
-def create_serial_batch_no_ledgers(entries, child_row, parent_doc, warehouse=None) -> object:
+def create_serial_batch_no_ledgers(
+	entries, child_row, parent_doc, warehouse=None, do_not_save=False
+) -> object:
 
 	warehouse = warehouse or (
 		child_row.rejected_warehouse if child_row.is_rejected else child_row.warehouse
 	)
 
-	type_of_transaction = child_row.type_of_transaction
+	type_of_transaction = get_type_of_transaction(parent_doc, child_row)
 	if parent_doc.get("doctype") == "Stock Entry":
-		type_of_transaction = "Outward" if child_row.s_warehouse else "Inward"
 		warehouse = warehouse or child_row.s_warehouse or child_row.t_warehouse
 
 	doc = frappe.get_doc(
@@ -1214,13 +1210,30 @@
 
 	doc.save()
 
-	frappe.db.set_value(child_row.doctype, child_row.name, "serial_and_batch_bundle", doc.name)
+	if do_not_save:
+		frappe.db.set_value(child_row.doctype, child_row.name, "serial_and_batch_bundle", doc.name)
 
 	frappe.msgprint(_("Serial and Batch Bundle created"), alert=True)
 
 	return doc
 
 
+def get_type_of_transaction(parent_doc, child_row):
+	type_of_transaction = child_row.type_of_transaction
+	if parent_doc.get("doctype") == "Stock Entry":
+		type_of_transaction = "Outward" if child_row.s_warehouse else "Inward"
+
+	if not type_of_transaction:
+		type_of_transaction = "Outward"
+		if parent_doc.get("doctype") in ["Purchase Receipt", "Purchase Invoice"]:
+			type_of_transaction = "Inward"
+
+	if parent_doc.get("is_return"):
+		type_of_transaction = "Inward" if type_of_transaction == "Outward" else "Outward"
+
+	return type_of_transaction
+
+
 def update_serial_batch_no_ledgers(entries, child_row, parent_doc, warehouse=None) -> object:
 	doc = frappe.get_doc("Serial and Batch Bundle", child_row.serial_and_batch_bundle)
 	doc.voucher_detail_no = child_row.name
@@ -1247,6 +1260,25 @@
 	return doc
 
 
+@frappe.whitelist()
+def update_serial_or_batch(bundle_id, serial_no=None, batch_no=None):
+	if batch_no and not serial_no:
+		if qty := frappe.db.get_value(
+			"Serial and Batch Entry", {"parent": bundle_id, "batch_no": batch_no}, "qty"
+		):
+			frappe.db.set_value(
+				"Serial and Batch Entry", {"parent": bundle_id, "batch_no": batch_no}, "qty", qty + 1
+			)
+			return
+
+	doc = frappe.get_cached_doc("Serial and Batch Bundle", bundle_id)
+	if not serial_no and not batch_no:
+		return
+
+	doc.append("entries", {"serial_no": serial_no, "batch_no": batch_no, "qty": 1})
+	doc.save(ignore_permissions=True)
+
+
 def get_serial_and_batch_ledger(**kwargs):
 	kwargs = frappe._dict(kwargs)
 
@@ -2032,3 +2064,8 @@
 @frappe.whitelist()
 def get_batch_no_from_serial_no(serial_no):
 	return frappe.get_cached_value("Serial No", serial_no, "batch_no")
+
+
+@frappe.whitelist()
+def is_duplicate_serial_no(bundle_id, serial_no):
+	return frappe.db.exists("Serial and Batch Entry", {"parent": bundle_id, "serial_no": serial_no})
diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py
index 39df227..4cfe5d8 100644
--- a/erpnext/stock/serial_batch_bundle.py
+++ b/erpnext/stock/serial_batch_bundle.py
@@ -209,7 +209,7 @@
 		frappe.db.set_value(
 			"Serial and Batch Bundle",
 			{"voucher_no": self.sle.voucher_no, "voucher_type": self.sle.voucher_type},
-			{"is_cancelled": 1, "voucher_no": ""},
+			{"is_cancelled": 1},
 		)
 
 		if self.sle.serial_and_batch_bundle:
diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py
index bd0d469..4b0e284 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -591,6 +591,13 @@
 		as_dict=True,
 	)
 	if batch_no_data:
+		if frappe.get_cached_value("Item", batch_no_data.item_code, "has_serial_no"):
+			frappe.throw(
+				_(
+					"Batch No {0} is linked with Item {1} which has serial no. Please scan serial no instead."
+				).format(search_value, batch_no_data.item_code)
+			)
+
 		_update_item_info(batch_no_data)
 		set_cache(batch_no_data)
 		return batch_no_data