diff --git a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json
index afab0d66c96..0169b282b9b 100644
--- a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json
+++ b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json
@@ -89,6 +89,8 @@
"item_tax_rate",
"actual_batch_qty",
"actual_qty",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html",
"section_break_tlhi",
"serial_no",
"column_break_ciit",
@@ -859,6 +861,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_tlhi",
@@ -877,7 +888,7 @@
],
"istable": 1,
"links": [],
- "modified": "2026-06-08 20:00:00.000000",
+ "modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice Item",
diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
index 5269fec916c..c5de538b897 100644
--- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
+++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
@@ -75,6 +75,10 @@
"quality_inspection",
"rejected_warehouse",
"rejected_serial_and_batch_bundle",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html",
+ "rejected_serial_batch_entries_section",
+ "rejected_serial_batch_entries_html",
"section_break_rqbe",
"serial_no",
"rejected_serial_no",
@@ -941,6 +945,24 @@
"label": "Use Serial No / Batch Fields",
"print_hide": 1
},
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
+ {
+ "fieldname": "rejected_serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Rejected Serial / Batch Entries"
+ },
+ {
+ "fieldname": "rejected_serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
{
"depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1 && parent.update_stock === 1",
"fieldname": "section_break_rqbe",
@@ -1010,7 +1032,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2026-06-08 21:00:00.000000",
+ "modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",
diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json
index 903803aa79f..7fd1ecc1400 100644
--- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json
+++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json
@@ -94,6 +94,8 @@
"incoming_rate",
"item_tax_rate",
"actual_batch_qty",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html",
"section_break_eoec",
"serial_no",
"column_break_ytgd",
@@ -954,6 +956,15 @@
"label": "Use Serial No / Batch Fields",
"print_hide": 1
},
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
{
"depends_on": "eval:doc.use_serial_batch_fields === 1 && parent.update_stock === 1",
"fieldname": "section_break_eoec",
@@ -1055,7 +1066,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2026-06-08 20:00:00.000000",
+ "modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",
diff --git a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json
index d5d0327916c..7022d240a7a 100644
--- a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json
+++ b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json
@@ -21,6 +21,8 @@
"serial_and_batch_bundle",
"use_serial_batch_fields",
"column_break_13",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html",
"section_break_bfqc",
"serial_no",
"column_break_mbuv",
@@ -165,6 +167,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_bfqc",
@@ -185,7 +196,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2025-03-05 12:46:01.074742",
+ "modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Capitalization Stock Item",
@@ -196,4 +207,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json
index 5ee245339eb..bb2304ab50d 100644
--- a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json
+++ b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json
@@ -13,7 +13,9 @@
"serial_no",
"column_break_xzfr",
"pick_serial_and_batch",
- "serial_and_batch_bundle"
+ "serial_and_batch_bundle",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html"
],
"fields": [
{
@@ -72,12 +74,21 @@
{
"fieldname": "column_break_xzfr",
"fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2025-06-27 14:52:56.311166",
+ "modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Repair Consumed Item",
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index ef59dc40acf..419a42e0b52 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -500,3 +500,4 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
erpnext.patches.v16_0.access_control_for_project_users
erpnext.patches.v16_0.enable_book_stock_expense_gl_entries
+execute:frappe.db.set_single_value("Stock Settings", "use_inline_serial_batch_editor", 0)
diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js
index ec579c459da..b937522d80f 100644
--- a/erpnext/public/js/erpnext.bundle.js
+++ b/erpnext/public/js/erpnext.bundle.js
@@ -6,6 +6,7 @@ import "./sms_manager";
import "./utils/party";
import "./controllers/stock_controller";
import "./utils/serial_no_batch_selector";
+import "./utils/serial_batch_inline_editor";
import "./payment/payments";
import "./templates/visual_plant_floor_template.html";
import "./plant_floor_visual/visual_plant";
diff --git a/erpnext/public/js/utils/serial_batch_inline_editor.js b/erpnext/public/js/utils/serial_batch_inline_editor.js
new file mode 100644
index 00000000000..93e6446648c
--- /dev/null
+++ b/erpnext/public/js/utils/serial_batch_inline_editor.js
@@ -0,0 +1,1399 @@
+frappe.provide("erpnext.stock");
+
+erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
+ constructor({ frm, cdt, cdn, wrapper, is_rejected }) {
+ this.frm = frm;
+ this.cdt = cdt;
+ this.cdn = cdn;
+ this.wrapper = $(wrapper);
+ this.is_rejected = cint(is_rejected);
+ this.bundle_field = this.is_rejected ? "rejected_serial_and_batch_bundle" : "serial_and_batch_bundle";
+ this.config = erpnext.stock.get_sbie_config(frm.doc.doctype, cdt) || {};
+ this.qty_field = this.is_rejected ? "rejected_qty" : this.config.qty_field || "qty";
+ this.start = 0;
+ this.page_length = 10;
+ this.total_count = 0;
+ this.server_total_count = 0;
+ this.server_total_qty = 0;
+ this.last_entries = [];
+ this.make();
+ }
+
+ get row() {
+ return locals[this.cdt][this.cdn];
+ }
+
+ get bundle() {
+ return this.row[this.bundle_field];
+ }
+
+ get pending_key() {
+ return `${this.cdn}::${this.is_rejected}`;
+ }
+
+ get pending() {
+ let store = erpnext.stock.get_sbie_pending_map(this.frm);
+ if (!store[this.pending_key]) {
+ store[this.pending_key] = { new_entries: [], updates: {}, deleted: [] };
+ }
+ return store[this.pending_key];
+ }
+
+ has_pending() {
+ let p = this.pending;
+ return Boolean(
+ p.delete_all || p.new_entries.length || p.deleted.length || Object.keys(p.updates).length
+ );
+ }
+
+ clear_pending() {
+ delete erpnext.stock.get_sbie_pending_map(this.frm)[this.pending_key];
+ }
+
+ toggle_section(show) {
+ this.wrapper.closest(".form-section").toggle(show);
+ }
+
+ async make() {
+ if (!this.row.item_code) {
+ this.wrapper.empty();
+ this.toggle_section(false);
+ return;
+ }
+
+ this.item = await frappe.db.get_value("Item", this.row.item_code, ["has_serial_no", "has_batch_no"]);
+ this.item = this.item.message || {};
+
+ if (!cint(this.item.has_serial_no) && !cint(this.item.has_batch_no)) {
+ this.wrapper.empty();
+ this.toggle_section(false);
+ return;
+ }
+
+ this.toggle_section(true);
+ this.render_skeleton();
+ this.load_page();
+ }
+
+ inject_styles() {
+ if ($("#serial-batch-inline-editor-styles").length) return;
+
+ $(``).appendTo("head");
+ }
+
+ esc(value) {
+ return frappe.utils.escape_html(cstr(value));
+ }
+
+ render_skeleton() {
+ this.inject_styles();
+ this.wrapper.html(`
+
+ `);
+ this.bind_events();
+ }
+
+ get_csv_columns() {
+ if (cint(this.item.has_serial_no) && cint(this.item.has_batch_no)) {
+ return ["Serial No", "Batch No", "Quantity"];
+ }
+
+ if (cint(this.item.has_batch_no)) {
+ return ["Batch No", "Quantity"];
+ }
+
+ return ["Serial No"];
+ }
+
+ download_csv() {
+ let url;
+ if (this.bundle) {
+ url = `/api/method/erpnext.stock.doctype.serial_and_batch_bundle.inline_editor.download_bundle_entries_csv?bundle=${encodeURIComponent(
+ this.bundle
+ )}`;
+ } else {
+ url = `/api/method/erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.download_blank_csv_template?content=${encodeURIComponent(
+ JSON.stringify(this.get_csv_columns())
+ )}`;
+ }
+
+ const w = window.open(frappe.urllib.get_full_url(url));
+ if (!w) {
+ frappe.msgprint(__("Please enable pop-ups"));
+ }
+ }
+
+ upload_csv() {
+ new frappe.ui.FileUploader({
+ allow_multiple: false,
+ restrictions: { allowed_file_types: [".csv"] },
+ on_success: (file) => this.import_csv_file(file.file_url),
+ });
+ }
+
+ async import_csv_file(file_url) {
+ let data = await this.call(
+ "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.upload_csv_file",
+ { item_code: this.row.item_code, file_path: file_url }
+ );
+
+ let entries = [];
+ if (data.serial_nos && data.serial_nos.length) {
+ entries = data.serial_nos;
+ } else if (data.batch_nos && data.batch_nos.length) {
+ entries = data.batch_nos;
+ }
+
+ if (!entries.length) {
+ frappe.msgprint(__("No entries found in the uploaded file"));
+ return;
+ }
+
+ if (this.server_total_count || this.has_pending()) {
+ frappe.confirm(__("This will replace the existing entries. Continue?"), () =>
+ this.replace_entries(entries)
+ );
+ } else {
+ this.replace_entries(entries);
+ }
+ }
+
+ async replace_entries(entries) {
+ this.clear_pending();
+ await this.upsert({ entries, replace: 1 });
+ if (this.frm.is_dirty()) {
+ this.frm.save();
+ }
+ }
+
+ async add_new_row() {
+ if (this.is_rejected && !this.row.rejected_warehouse) {
+ frappe.msgprint(__("Please set Rejected Warehouse first"));
+ return;
+ }
+
+ let $pending = this.wrapper.find(".sbie-new-row");
+ if ($pending.length) {
+ this.commit_new_row($pending);
+ if (this.wrapper.find(".sbie-new-row").length) {
+ this.wrapper.find(".sbie-new-row input").first().focus();
+ return;
+ }
+ }
+
+ let $tbody = this.wrapper.find(".sbie-table tbody");
+ if (!$tbody.length) return;
+
+ this.wrapper.find(".sbie-empty").remove();
+ this.wrapper.find(".sbie-table").css("overflow", "visible");
+ let $tr = $(this.get_new_row_html()).appendTo($tbody);
+ this.make_new_row_controls($tr);
+ }
+
+ get_new_row_html() {
+ let show_serial = cint(this.item.has_serial_no);
+ let show_batch = cint(this.item.has_batch_no);
+ let qty_cell = show_serial
+ ? this.format_float(1)
+ : ``;
+
+ return `
+ |
+ ${this.get_effective_count() + 1} |
+ ${show_serial ? ` | ` : ""}
+ ${show_batch ? ` | ` : ""}
+ ${qty_cell} |
+
`;
+ }
+
+ make_new_row_controls($tr) {
+ this.new_serial_control = this.make_row_link_control($tr.find(".sbie-new-serial"), {
+ options: "Serial No",
+ fieldname: "sbie_new_serial",
+ placeholder: __("Scan / select Serial No"),
+ get_query: () => ({ filters: { item_code: this.row.item_code } }),
+ onchange: () => this.on_new_serial_change($tr),
+ });
+
+ this.new_batch_control = this.make_row_link_control($tr.find(".sbie-new-batch"), {
+ options: "Batch",
+ fieldname: "sbie_new_batch",
+ placeholder: __("Select Batch No"),
+ get_query: () => ({ filters: { item: this.row.item_code, disabled: 0 } }),
+ onchange: () => this.on_new_batch_change($tr),
+ });
+
+ $tr.find(".sbie-new-check")
+ .on("mousedown", () => $tr.data("cancelled", 1))
+ .on("change", (e) => {
+ $tr.data("cancelled", e.target.checked ? 1 : 0);
+ this.toggle_delete_button();
+ });
+ $tr.find("input").on("keydown", (e) => {
+ if (e.which === 13) this.commit_new_row($tr);
+ });
+ $tr.find(".sbie-new-qty")
+ .on("input", (e) => this.restrict_to_numeric(e))
+ .on("focus", (e) => e.target.select())
+ .on("change", () => this.commit_new_row($tr))
+ .on("blur", () => this.commit_new_row($tr));
+
+ let first_control = this.new_serial_control || this.new_batch_control;
+ first_control && first_control.$wrapper.find("input").focus();
+ }
+
+ make_row_link_control($slot, df) {
+ if (!$slot.length) return null;
+
+ let control = frappe.ui.form.make_control({
+ parent: $slot,
+ df: Object.assign({ fieldtype: "Link" }, df),
+ render_input: true,
+ });
+
+ this.make_control_compact(control);
+ return control;
+ }
+
+ make_control_compact(control) {
+ let $wrapper = control.$wrapper;
+ $wrapper.find(".control-label, .help-box").hide();
+ $wrapper.find(".form-group").css({ margin: "0", "min-height": "0" });
+ $wrapper.find("input").css({ "min-height": "0" });
+ $wrapper.css({ margin: "0", "min-height": "0" });
+ }
+
+ on_new_serial_change($tr) {
+ if (!this.new_serial_control || !this.new_serial_control.get_value()) return;
+
+ if (this.new_batch_control && !this.new_batch_control.get_value()) {
+ this.new_batch_control.$wrapper.find("input").focus();
+ return;
+ }
+
+ this.commit_new_row($tr);
+ }
+
+ on_new_batch_change($tr) {
+ if (!this.new_batch_control || !this.new_batch_control.get_value()) return;
+
+ if (this.new_serial_control) {
+ if (this.new_serial_control.get_value()) {
+ this.commit_new_row($tr);
+ }
+ return;
+ }
+
+ let committed = this.commit_new_row($tr);
+ committed &&
+ committed.then(() => {
+ this.wrapper.find(".sbie-qty-input[data-pending-index]").last().focus();
+ });
+ }
+
+ edit_batch_cell($td) {
+ this.edit_link_cell($td, {
+ options: "Batch",
+ field: "batch_no",
+ placeholder: __("Select Batch No"),
+ get_query: () => ({ filters: { item: this.row.item_code, disabled: 0 } }),
+ });
+ }
+
+ edit_serial_cell($td) {
+ this.edit_link_cell($td, {
+ options: "Serial No",
+ field: "serial_no",
+ placeholder: __("Select Serial No"),
+ get_query: () => ({ filters: { item_code: this.row.item_code } }),
+ });
+ }
+
+ edit_link_cell($td, opts) {
+ if ($td.data("editing")) return;
+ $td.data("editing", 1);
+
+ let name = $td.data("name");
+ let current = $td.text().trim();
+ $td.empty().addClass("sbie-input-cell").css("cursor", "default");
+ this.wrapper.find(".sbie-table").css("overflow", "visible");
+
+ let control = this.make_row_link_control($td, {
+ options: opts.options,
+ fieldname: "sbie_edit_link",
+ placeholder: opts.placeholder,
+ get_query: opts.get_query,
+ onchange: () => {
+ let value = control.get_value();
+ if (value && value !== current) {
+ this.update_entry(name, { [opts.field]: value });
+ this.refresh_view();
+ }
+ },
+ });
+
+ control.set_input(current);
+ control.$wrapper.find("input").focus();
+ }
+
+ commit_new_row($tr) {
+ if ($tr.data("committing") || $tr.data("cancelled")) return;
+
+ let serial_no = this.new_serial_control ? this.new_serial_control.get_value() : "";
+ let batch_no = this.new_batch_control ? this.new_batch_control.get_value() : "";
+ if (!serial_no && !batch_no) return;
+
+ let qty = serial_no ? 1 : flt($tr.find(".sbie-new-qty").val()) || 1;
+
+ $tr.data("committing", 1);
+ this.pending.new_entries.push({ serial_no, batch_no, qty });
+ this.frm.dirty();
+ return this.go_to_last_page();
+ }
+
+ update_entry(name, changes) {
+ let updates = this.pending.updates;
+ if (!updates[name]) {
+ let entry = this.last_entries.find((d) => d.name === name) || {};
+ updates[name] = { orig_qty: Math.abs(flt(entry.qty)) };
+ }
+
+ Object.assign(updates[name], changes);
+ this.frm.dirty();
+ }
+
+ bind_events() {
+ this.wrapper.find(".sbie-add-row").on("click", () => this.add_new_row());
+ this.wrapper.find(".sbie-upload-csv").on("click", () => this.upload_csv());
+ this.wrapper.find(".sbie-download-csv").on("click", () => this.download_csv());
+ this.wrapper.find(".sbie-prev").on("click", () => this.change_page(-1));
+ this.wrapper.find(".sbie-next").on("click", () => this.change_page(1));
+ this.wrapper.find(".sbie-first-page").on("click", () => this.go_to_page(1));
+ this.wrapper.find(".sbie-last-page").on("click", () => this.go_to_page(this.total_pages));
+ this.wrapper
+ .find(".sbie-page-number")
+ .on("input", (e) => {
+ e.target.value = e.target.value.replace(/[^0-9]/g, "");
+ e.target.style.width = (e.target.value.length + 1) * 8 + "px";
+ })
+ .on("keydown", (e) => {
+ if (e.which === 13) e.target.blur();
+ })
+ .on("blur", (e) => this.go_to_page(e.target.value))
+ .on("focus", (e) => e.target.select());
+ this.wrapper.find(".sbie-delete").on("click", () => this.delete_selected());
+ this.wrapper.find(".sbie-scan-action").on("click", () => this.open_scan_dialog());
+ this.wrapper.find(".sbie-range-action").on("click", () => this.open_range_dialog());
+ this.wrapper.find(".sbie-auto-fetch-action").on("click", () => this.open_auto_fetch_dialog());
+ }
+
+ get_type_of_transaction() {
+ let doc = this.frm.doc;
+ if (doc.doctype === "Stock Entry") {
+ return this.row.s_warehouse ? "Outward" : "Inward";
+ }
+
+ let inward =
+ ["Purchase Receipt", "Purchase Invoice", "Stock Reconciliation"].includes(doc.doctype) ||
+ this.cdt === "Subcontracting Receipt Item";
+
+ if (doc.is_return) {
+ inward = !inward;
+ }
+
+ return inward ? "Inward" : "Outward";
+ }
+
+ async open_auto_fetch_dialog() {
+ let warehouse = this.row.warehouse || this.row.s_warehouse;
+ if (!warehouse) {
+ frappe.msgprint(__("Please set Warehouse first"));
+ return;
+ }
+
+ let is_serial = cint(this.item.has_serial_no);
+ let based_on = await erpnext.stock.get_pick_serial_batch_based_on();
+
+ let dialog = new frappe.ui.Dialog({
+ title: is_serial ? __("Auto Fetch Serial Nos") : __("Auto Fetch Batch Nos"),
+ fields: [
+ {
+ fieldtype: "Float",
+ fieldname: "qty",
+ label: __("Qty to Fetch"),
+ reqd: 1,
+ default: Math.abs(flt(this.row[this.qty_field])) || null,
+ description: __("Existing entries will be replaced with the fetched entries"),
+ },
+ {
+ fieldtype: "Select",
+ fieldname: "based_on",
+ label: __("Fetch Based On"),
+ options: ["FIFO", "LIFO", "Expiry"],
+ default: based_on,
+ },
+ ],
+ primary_action_label: __("Fetch"),
+ primary_action: (values) => {
+ dialog.hide();
+ this.auto_fetch_entries(values.qty, values.based_on, warehouse);
+ },
+ });
+
+ dialog.show();
+ }
+
+ async auto_fetch_entries(qty, based_on, warehouse) {
+ let data = await this.call(
+ "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.get_auto_data",
+ {
+ item_code: this.row.item_code,
+ warehouse: warehouse,
+ has_serial_no: this.item.has_serial_no,
+ has_batch_no: this.item.has_batch_no,
+ qty: qty,
+ based_on: based_on,
+ posting_date: this.frm.doc.posting_date,
+ posting_time: this.frm.doc.posting_time,
+ }
+ );
+
+ if (!data || !data.length) {
+ frappe.msgprint(
+ __("No stock available for Item {0} in Warehouse {1}", [
+ this.esc(this.row.item_code),
+ this.esc(warehouse),
+ ])
+ );
+ return;
+ }
+
+ this.add_auto_fetched_entries(data);
+ }
+
+ add_auto_fetched_entries(rows) {
+ let p = this.pending;
+ p.delete_all = 1;
+ p.new_entries = [];
+ p.updates = {};
+ p.deleted = [];
+
+ for (const row of rows) {
+ p.new_entries.push({
+ serial_no: row.serial_no || "",
+ batch_no: row.batch_no || "",
+ qty: Math.abs(flt(row.qty)) || 1,
+ });
+ }
+
+ this.start = 0;
+ this.frm.dirty();
+ this.go_to_last_page();
+ frappe.show_alert({
+ message: __("{0} entries fetched", [p.new_entries.length]),
+ indicator: "green",
+ });
+ this.frm.save();
+ }
+
+ open_scan_dialog() {
+ if (this.is_rejected && !this.row.rejected_warehouse) {
+ frappe.msgprint(__("Please set Rejected Warehouse first"));
+ return;
+ }
+
+ let is_serial = cint(this.item.has_serial_no);
+ let scanned_count = 0;
+
+ let dialog = new frappe.ui.Dialog({
+ title: is_serial ? __("Scan Serial Nos") : __("Scan Batch Nos"),
+ fields: [
+ {
+ fieldtype: "Data",
+ fieldname: "scan_value",
+ options: "Barcode",
+ label: is_serial ? __("Scan Serial No") : __("Scan Batch No"),
+ description: __("Missing Serial / Batch Nos will be created on Save"),
+ onchange: () => {
+ let value = (dialog.get_value("scan_value") || "").trim();
+ if (!value) return;
+
+ if (this.add_scanned_value(value)) {
+ scanned_count++;
+ }
+ dialog.fields_dict.scanned_info.$wrapper.html(
+ `${__("Scanned: {0}", [
+ scanned_count,
+ ])} · ${frappe.utils.escape_html(value)}
`
+ );
+ dialog.set_value("scan_value", "");
+ },
+ },
+ { fieldtype: "HTML", fieldname: "scanned_info" },
+ ],
+ on_hide: () => this.refresh_view(),
+ });
+
+ dialog.show();
+ }
+
+ get_active_server_row(field, value) {
+ let p = this.pending;
+ if (p.delete_all) return null;
+
+ return this.last_entries.find((d) => d[field] === value && !p.deleted.some((x) => x.name === d.name));
+ }
+
+ get_known_identifiers() {
+ let p = this.pending;
+ let known = new Set(p.new_entries.map((d) => d.serial_no || d.batch_no));
+
+ if (!p.delete_all) {
+ let deleted = new Set(p.deleted.map((d) => d.name));
+ for (const d of this.last_entries) {
+ if (!deleted.has(d.name)) {
+ known.add(d.serial_no || d.batch_no);
+ }
+ }
+ }
+
+ return known;
+ }
+
+ add_scanned_value(value) {
+ let p = this.pending;
+
+ if (cint(this.item.has_serial_no)) {
+ if (this.get_known_identifiers().has(value)) {
+ frappe.show_alert({
+ message: __("Serial No {0} already added", [this.esc(value)]),
+ indicator: "orange",
+ });
+ return false;
+ }
+
+ p.new_entries.push({ serial_no: value, batch_no: "", qty: 1 });
+ } else {
+ let existing = p.new_entries.find((d) => d.batch_no === value);
+ let server_row = this.get_active_server_row("batch_no", value);
+ if (existing) {
+ existing.qty = flt(existing.qty) + 1;
+ } else if (server_row) {
+ let update = p.updates[server_row.name];
+ let current = update && update.qty != null ? flt(update.qty) : Math.abs(flt(server_row.qty));
+ this.update_entry(server_row.name, { qty: current + 1 });
+ } else {
+ p.new_entries.push({ serial_no: "", batch_no: value, qty: 1 });
+ }
+ }
+
+ this.frm.dirty();
+ this.go_to_last_page();
+ return true;
+ }
+
+ open_range_dialog() {
+ if (this.is_rejected && !this.row.rejected_warehouse) {
+ frappe.msgprint(__("Please set Rejected Warehouse first"));
+ return;
+ }
+
+ let dialog = new frappe.ui.Dialog({
+ title: __("Create Serial Nos from Range"),
+ fields: [
+ {
+ fieldtype: "Data",
+ fieldname: "serial_no_range",
+ label: __("Serial No Range"),
+ reqd: 1,
+ description: __(
+ '"SN-01::10" for "SN-01" to "SN-10". Missing Serial Nos will be created on Save'
+ ),
+ },
+ ],
+ primary_action_label: __("Add"),
+ primary_action: ({ serial_no_range }) => {
+ let serial_nos = erpnext.stock.utils.get_serial_range(serial_no_range, "::");
+ if (!serial_nos || !serial_nos.length) {
+ frappe.throw(__("Invalid range. Use the format {0}", ["SN-01::10"]));
+ }
+
+ dialog.hide();
+ this.add_serial_range(serial_nos);
+ },
+ });
+
+ dialog.show();
+ }
+
+ add_serial_range(serial_nos) {
+ let p = this.pending;
+ let known = this.get_known_identifiers();
+
+ let added = 0;
+ for (const serial_no of serial_nos) {
+ if (known.has(serial_no)) continue;
+ p.new_entries.push({ serial_no: serial_no, batch_no: "", qty: 1 });
+ added++;
+ }
+
+ this.frm.dirty();
+ this.go_to_last_page();
+ frappe.show_alert({
+ message: __("{0} Serial Nos added. They will be saved with the document.", [added]),
+ indicator: "green",
+ });
+ }
+
+ get total_pages() {
+ return Math.ceil(this.get_effective_count() / this.page_length) || 1;
+ }
+
+ go_to_last_page() {
+ this.start = (this.total_pages - 1) * this.page_length;
+ return this.load_page();
+ }
+
+ change_page(direction) {
+ let current_page = Math.floor(this.start / this.page_length) + 1;
+ this.go_to_page(current_page + direction);
+ }
+
+ go_to_page(index) {
+ index = Math.min(Math.max(cint(index) || 1, 1), this.total_pages);
+ let new_start = (index - 1) * this.page_length;
+
+ if (new_start === this.start) {
+ this.wrapper.find(".sbie-page-number").val(index);
+ return;
+ }
+
+ this.start = new_start;
+ this.load_page();
+ }
+
+ async load_page() {
+ if (!this.bundle) {
+ this.server_total_count = 0;
+ this.server_total_qty = 0;
+ this.last_entries = [];
+ this._totals_loaded = true;
+ } else if (!this._totals_loaded || this.start < this.server_total_count) {
+ let data = await this.call(
+ "erpnext.stock.doctype.serial_and_batch_bundle.inline_editor.get_bundle_entries",
+ {
+ bundle: this.bundle,
+ start: this.start,
+ page_length: this.page_length,
+ }
+ );
+ this.server_total_count = data.total_count;
+ this.server_total_qty = flt(data.total_qty);
+ this.last_entries = data.entries;
+ this._totals_loaded = true;
+ } else {
+ this.last_entries = [];
+ }
+
+ this.refresh_view();
+ this.reconcile_row_qty();
+ }
+
+ refresh_view() {
+ this.render_rows(this.last_entries);
+ this.update_summary();
+ this.sync_row_qty();
+ }
+
+ get_effective_count() {
+ let p = this.pending;
+ if (p.delete_all) {
+ return p.new_entries.length;
+ }
+
+ return this.server_total_count + p.new_entries.length - p.deleted.length;
+ }
+
+ get_effective_qty() {
+ let p = this.pending;
+ let qty = p.delete_all ? 0 : this.server_total_qty;
+
+ for (const row of p.new_entries) {
+ qty += flt(row.qty);
+ }
+
+ if (!p.delete_all) {
+ for (const name in p.updates) {
+ const u = p.updates[name];
+ if (u.qty != null) {
+ qty += flt(u.qty) - flt(u.orig_qty);
+ }
+ }
+ for (const d of p.deleted) {
+ qty -= flt(d.qty);
+ }
+ }
+
+ return flt(qty, cint(frappe.boot.sysdefaults && frappe.boot.sysdefaults.float_precision) || 3);
+ }
+
+ sync_row_qty() {
+ if (this.frm.doc.docstatus !== 0 || !this.has_pending()) return;
+
+ let expected = this.get_effective_qty();
+ if (flt(this.row[this.qty_field]) !== expected) {
+ frappe.model.set_value(this.cdt, this.cdn, this.qty_field, expected);
+ }
+ }
+
+ reconcile_row_qty() {
+ if (this.frm.doc.docstatus !== 0 || this.has_pending() || !this.server_total_count) return;
+
+ if (flt(this.row[this.qty_field]) !== this.server_total_qty) {
+ frappe.model.set_value(this.cdt, this.cdn, this.qty_field, this.server_total_qty);
+ frappe.show_alert({
+ message: __(
+ "Qty updated to {0} to match the Serial and Batch Bundle. Please save the document.",
+ [this.server_total_qty]
+ ),
+ indicator: "orange",
+ });
+ }
+ }
+
+ render_rows(entries) {
+ let p = this.pending;
+ let show_batch = cint(this.item.has_batch_no);
+ let show_serial = cint(this.item.has_serial_no);
+ let column_count = 3 + show_serial + show_batch;
+
+ let header = `
+ |
+ ${__("No")} |
+ ${show_serial ? `${__("Serial No")} | ` : ""}
+ ${show_batch ? `${__("Batch No")} | ` : ""}
+ ${__("Qty")} |
+
`;
+
+ let visible = p.delete_all ? [] : entries.filter((d) => !p.deleted.some((x) => x.name === d.name));
+ let body = visible
+ .map((d, i) => {
+ let update = p.updates[d.name] || {};
+ let qty = update.qty != null ? flt(update.qty) : Math.abs(flt(d.qty));
+ let batch_no = this.esc(update.batch_no || d.batch_no || "");
+ let serial_no = this.esc(update.serial_no || d.serial_no || "");
+ let name = this.esc(d.name);
+
+ return `
+ |
+ |
+ ${this.start + i + 1} |
+ ${
+ show_serial
+ ? `${serial_no} | `
+ : ""
+ }
+ ${
+ show_batch
+ ? `${batch_no} | `
+ : ""
+ }
+ ${
+ !d.serial_no && show_batch ? this.get_qty_input(d, qty) : this.format_float(qty)
+ } |
+
`;
+ })
+ .join("");
+
+ let base_count = p.delete_all ? 0 : this.server_total_count - p.deleted.length;
+ let pending_offset = Math.max(0, this.start - (p.delete_all ? 0 : this.server_total_count));
+ let capacity = Math.max(this.page_length - visible.length, 0);
+ body += p.new_entries
+ .slice(pending_offset, pending_offset + capacity)
+ .map((d, i) => {
+ let index = pending_offset + i;
+ return `
+ |
+ |
+ ${base_count + index + 1} |
+ ${show_serial ? `${this.esc(d.serial_no || "")} | ` : ""}
+ ${show_batch ? `${this.esc(d.batch_no || "")} | ` : ""}
+ ${
+ !d.serial_no && show_batch
+ ? this.get_pending_qty_input(d, index)
+ : this.format_float(d.qty)
+ } |
+
`;
+ })
+ .join("");
+
+ if (!visible.length && !p.new_entries.length) {
+ body = `|
+ ${__("Click on 'Add row' to add Serial / Batch entries")} |
`;
+ }
+
+ this.wrapper
+ .find(".sbie-table")
+ .css("overflow", "")
+ .html(``);
+
+ this.wrapper.find(".sbie-check-all").on("change", (e) => {
+ this.wrapper.find(".sbie-check").prop("checked", e.target.checked);
+ this.toggle_delete_button();
+ });
+ this.wrapper.find(".sbie-check").on("change", (e) => {
+ if (!e.target.checked) {
+ this.wrapper.find(".sbie-check-all").prop("checked", false);
+ }
+ this.toggle_delete_button();
+ });
+ this.wrapper.find(".sbie-batch-cell").on("click", (e) => this.edit_batch_cell($(e.currentTarget)));
+ this.wrapper.find(".sbie-serial-cell").on("click", (e) => this.edit_serial_cell($(e.currentTarget)));
+ this.wrapper.find(".sbie-qty-input").on("input", (e) => this.restrict_to_numeric(e));
+ this.wrapper.find(".sbie-qty-input").on("blur", (e) => this.apply_float_format(e));
+ this.wrapper.find(".sbie-qty-input").on("change", (e) => this.update_qty(e));
+ this.wrapper.find(".sbie-qty-input").on("focus", (e) => e.target.select());
+ this.toggle_delete_button();
+ }
+
+ get_qty_input(d, qty) {
+ return ``;
+ }
+
+ get_pending_qty_input(d, index) {
+ return ``;
+ }
+
+ format_float(value) {
+ let precision = cint(frappe.boot.sysdefaults && frappe.boot.sysdefaults.float_precision) || 3;
+ let formatted = flt(value, precision).toFixed(precision).replace(/0+$/, "");
+ if (formatted.endsWith(".")) {
+ formatted += "0";
+ }
+ return formatted;
+ }
+
+ restrict_to_numeric(e) {
+ let $input = $(e.target);
+ let value = $input
+ .val()
+ .replace(/[^0-9.]/g, "")
+ .replace(/(\..*)\./g, "$1");
+ if (value !== $input.val()) {
+ $input.val(value);
+ }
+ }
+
+ apply_float_format(e) {
+ let $input = $(e.target);
+ if ($input.val() !== "") {
+ $input.val(this.format_float($input.val()));
+ }
+ }
+
+ toggle_delete_button() {
+ let checked = this.wrapper.find(".sbie-check:checked").length;
+ let select_all = this.wrapper.find(".sbie-check-all").prop("checked");
+ this.wrapper
+ .find(".sbie-delete")
+ .toggleClass("hidden", !checked)
+ .text(select_all ? __("Delete All") : __("Delete row"));
+ }
+
+ update_summary() {
+ this.total_count = this.server_total_count;
+ this.wrapper.find(".sbie-summary").text(__("Total Qty: {0}", [this.get_effective_qty()]));
+
+ let current_page = Math.floor(this.start / this.page_length) + 1;
+ this.wrapper
+ .find(".sbie-pagination")
+ .toggleClass("hidden", this.get_effective_count() <= this.page_length);
+ this.wrapper
+ .find(".sbie-page-number")
+ .val(current_page)
+ .css("width", (String(current_page).length + 1) * 8 + "px");
+ this.wrapper.find(".sbie-total-pages").text(this.total_pages);
+ }
+
+ update_qty(e) {
+ let $input = $(e.target);
+ let qty = flt($input.val()) || 1;
+
+ if ($input.data("pending-index") != null) {
+ this.pending.new_entries[$input.data("pending-index")].qty = qty;
+ } else {
+ this.update_entry($input.data("name"), { qty: qty });
+ }
+
+ this.update_summary();
+ this.sync_row_qty();
+ }
+
+ delete_selected() {
+ if (this.wrapper.find(".sbie-check-all").prop("checked")) {
+ this.delete_all_entries();
+ return;
+ }
+
+ let p = this.pending;
+ let pending_indexes = [];
+
+ this.wrapper.find(".sbie-check:checked").each((_, el) => {
+ let $el = $(el);
+ if ($el.data("pending-index") != null) {
+ pending_indexes.push($el.data("pending-index"));
+ } else if ($el.data("name")) {
+ let name = $el.data("name");
+ delete p.updates[name];
+ p.deleted.push({ name: name, qty: flt($el.data("qty")) });
+ }
+ });
+
+ p.new_entries = p.new_entries.filter((_, i) => !pending_indexes.includes(i));
+ this.frm.dirty();
+ this.refresh_view();
+ }
+
+ delete_all_entries() {
+ frappe.confirm(
+ __("This will delete all {0} entries. Continue?", [this.get_effective_count()]),
+ () => {
+ let p = this.pending;
+ p.delete_all = 1;
+ p.new_entries = [];
+ p.updates = {};
+ p.deleted = [];
+ this.frm.dirty();
+ this.start = 0;
+ this.refresh_view();
+ }
+ );
+ }
+
+ async upsert({ entries = [], deleted = [], replace = 0 }) {
+ let summary = await this.call(
+ "erpnext.stock.doctype.serial_and_batch_bundle.inline_editor.upsert_bundle_entries",
+ {
+ child_row: Object.assign({}, this.row, { is_rejected: this.is_rejected }),
+ doc: this.frm.doc,
+ entries: entries,
+ deleted: deleted,
+ replace: replace,
+ }
+ );
+
+ if (this.bundle !== summary.bundle) {
+ await frappe.model.set_value(this.cdt, this.cdn, this.bundle_field, summary.bundle);
+ }
+ await frappe.model.set_value(this.cdt, this.cdn, this.qty_field, summary.total_qty);
+
+ this._totals_loaded = false;
+ await this.load_page();
+ }
+
+ call(method, args) {
+ return new Promise((resolve, reject) => {
+ frappe.call({
+ method: method,
+ args: args,
+ callback: (r) => resolve(r.message),
+ error: reject,
+ });
+ });
+ }
+};
+
+erpnext.stock.SBIE_DOCTYPES = [
+ { parent: "Purchase Receipt", child: "Purchase Receipt Item", table: "items" },
+ { parent: "Purchase Invoice", child: "Purchase Invoice Item", table: "items" },
+ { parent: "Sales Invoice", child: "Sales Invoice Item", table: "items" },
+ { parent: "Sales Invoice", child: "Packed Item", table: "packed_items" },
+ { parent: "POS Invoice", child: "POS Invoice Item", table: "items" },
+ { parent: "POS Invoice", child: "Packed Item", table: "packed_items" },
+ { parent: "Delivery Note", child: "Delivery Note Item", table: "items" },
+ { parent: "Delivery Note", child: "Packed Item", table: "packed_items" },
+ { parent: "Stock Entry", child: "Stock Entry Detail", table: "items" },
+ { parent: "Stock Reconciliation", child: "Stock Reconciliation Item", table: "items" },
+ { parent: "Subcontracting Receipt", child: "Subcontracting Receipt Item", table: "items" },
+ {
+ parent: "Subcontracting Receipt",
+ child: "Subcontracting Receipt Supplied Item",
+ table: "supplied_items",
+ qty_field: "consumed_qty",
+ },
+ { parent: "Pick List", child: "Pick List Item", table: "locations" },
+ {
+ parent: "Asset Capitalization",
+ child: "Asset Capitalization Stock Item",
+ table: "stock_items",
+ qty_field: "stock_qty",
+ },
+ {
+ parent: "Asset Repair",
+ child: "Asset Repair Consumed Item",
+ table: "stock_items",
+ qty_field: "consumed_quantity",
+ },
+];
+
+erpnext.stock.get_sbie_config = function (doctype, child_doctype) {
+ return erpnext.stock.SBIE_DOCTYPES.find((d) => d.parent === doctype && d.child === child_doctype);
+};
+
+erpnext.stock.get_sbie_row = function (frm, cdn) {
+ for (let config of erpnext.stock.SBIE_DOCTYPES) {
+ if (config.parent !== frm.doc.doctype) continue;
+
+ let row = (frm.doc[config.table] || []).find((d) => d.name === cdn);
+ if (row) return { row, config };
+ }
+
+ return {};
+};
+
+erpnext.stock.get_sbie_pending_map = function (frm) {
+ let store = (frm._sbie_pending = frm._sbie_pending || {});
+ return (store[frm.doc.name] = store[frm.doc.name] || {});
+};
+
+erpnext.stock.flush_serial_batch_pending = async function (frm) {
+ let pending_map = erpnext.stock.get_sbie_pending_map(frm);
+
+ for (let key of Object.keys(pending_map)) {
+ let p = pending_map[key];
+ let has_changes =
+ p.delete_all || p.new_entries.length || p.deleted.length || Object.keys(p.updates).length;
+ if (!has_changes) {
+ delete pending_map[key];
+ continue;
+ }
+
+ let [cdn, is_rejected] = key.split("::");
+ let { row, config } = erpnext.stock.get_sbie_row(frm, cdn);
+ if (!row) {
+ delete pending_map[key];
+ continue;
+ }
+
+ let bundle_field = cint(is_rejected) ? "rejected_serial_and_batch_bundle" : "serial_and_batch_bundle";
+ if (p.delete_all && !row[bundle_field] && !p.new_entries.length) {
+ delete pending_map[key];
+ continue;
+ }
+
+ let entries = p.new_entries.concat(
+ Object.keys(p.updates).map((name) => {
+ let update = { name: name };
+ if (p.updates[name].qty != null) update.qty = p.updates[name].qty;
+ if (p.updates[name].batch_no) update.batch_no = p.updates[name].batch_no;
+ if (p.updates[name].serial_no) update.serial_no = p.updates[name].serial_no;
+ return update;
+ })
+ );
+
+ let summary = await frappe.xcall(
+ "erpnext.stock.doctype.serial_and_batch_bundle.inline_editor.upsert_bundle_entries",
+ {
+ child_row: Object.assign({}, row, { is_rejected: cint(is_rejected) }),
+ doc: frm.doc,
+ entries: entries,
+ deleted: p.deleted.map((d) => d.name),
+ replace: cint(p.delete_all),
+ }
+ );
+
+ row[bundle_field] = summary.bundle;
+ row[cint(is_rejected) ? "rejected_qty" : config.qty_field || "qty"] = summary.total_qty;
+ if (row.received_qty != null) {
+ row.received_qty = flt(row.qty) + flt(row.rejected_qty);
+ }
+ delete pending_map[key];
+ }
+};
+
+erpnext.stock.mount_serial_batch_inline_editor = async function (frm, cdt, cdn) {
+ let config = erpnext.stock.get_sbie_config(frm.doc.doctype, cdt);
+ if (!config || !frm.fields_dict[config.table]) return;
+
+ let grid_row = frm.fields_dict[config.table].grid.grid_rows_by_docname[cdn];
+ let grid_form = grid_row && grid_row.grid_form;
+ if (!grid_form) return;
+
+ let editors = [
+ { fieldname: "serial_batch_entries_html", is_rejected: 0 },
+ { fieldname: "rejected_serial_batch_entries_html", is_rejected: 1 },
+ ];
+
+ let enabled = await erpnext.stock.is_inline_serial_batch_editor_enabled();
+ let row = locals[cdt][cdn];
+ let show = enabled && row && !row.use_serial_batch_fields && frm.doc.docstatus === 0;
+
+ erpnext.stock.toggle_legacy_bundle_fields(grid_form, show);
+
+ let editors_store = (frm._sbie_editors = frm._sbie_editors || {});
+
+ for (let editor of editors) {
+ let field = grid_form.fields_dict[editor.fieldname];
+ if (!field) continue;
+
+ if (!show) {
+ field.$wrapper.closest(".form-section").hide();
+ continue;
+ }
+
+ let key = `${cdn}::${editor.is_rejected}`;
+ let existing = editors_store[key];
+ if (
+ existing &&
+ existing.wrapper[0] === field.$wrapper[0] &&
+ document.body.contains(field.$wrapper[0]) &&
+ existing.wrapper.find(".serial-batch-inline-editor").length
+ ) {
+ continue;
+ }
+
+ editors_store[key] = new erpnext.stock.SerialBatchInlineEditor({
+ frm,
+ cdt,
+ cdn,
+ wrapper: field.$wrapper,
+ is_rejected: editor.is_rejected,
+ });
+ }
+};
+
+erpnext.stock.toggle_legacy_bundle_fields = function (grid_form, editor_active) {
+ let legacy_fields = [
+ "add_serial_batch_bundle",
+ "pick_serial_and_batch",
+ "serial_and_batch_bundle",
+ "add_serial_batch_for_rejected_qty",
+ "rejected_serial_and_batch_bundle",
+ ];
+
+ for (let fieldname of legacy_fields) {
+ let field = grid_form.fields_dict[fieldname];
+ if (!field) continue;
+
+ if (editor_active) {
+ field.$wrapper.hide();
+ } else {
+ field.refresh();
+ }
+ }
+};
+
+erpnext.stock.setup_serial_batch_pending_flush = function (doctype) {
+ frappe.ui.form.on(doctype, {
+ validate(frm) {
+ return erpnext.stock.flush_serial_batch_pending(frm);
+ },
+ });
+};
+
+erpnext.stock.setup_inline_serial_batch_editor = function () {
+ new Set(erpnext.stock.SBIE_DOCTYPES.map((d) => d.parent)).forEach((doctype) =>
+ erpnext.stock.setup_serial_batch_pending_flush(doctype)
+ );
+
+ new Set(erpnext.stock.SBIE_DOCTYPES.map((d) => d.child)).forEach((child_doctype) => {
+ frappe.ui.form.on(child_doctype, {
+ form_render(frm, cdt, cdn) {
+ erpnext.stock.mount_serial_batch_inline_editor(frm, cdt, cdn);
+ },
+ use_serial_batch_fields(frm, cdt, cdn) {
+ erpnext.stock.mount_serial_batch_inline_editor(frm, cdt, cdn);
+ },
+ });
+ });
+};
+
+erpnext.stock.setup_inline_serial_batch_editor();
+
+erpnext.stock.is_inline_serial_batch_editor_enabled = async function () {
+ if (erpnext.stock._inline_editor_enabled === undefined) {
+ let { message } = await frappe.db.get_value(
+ "Stock Settings",
+ "Stock Settings",
+ "use_inline_serial_batch_editor"
+ );
+ erpnext.stock._inline_editor_enabled = cint(message && message.use_inline_serial_batch_editor);
+ }
+
+ return erpnext.stock._inline_editor_enabled;
+};
+
+erpnext.stock.get_pick_serial_batch_based_on = async function () {
+ if (erpnext.stock._pick_serial_batch_based_on === undefined) {
+ let { message } = await frappe.db.get_value(
+ "Stock Settings",
+ "Stock Settings",
+ "pick_serial_and_batch_based_on"
+ );
+ erpnext.stock._pick_serial_batch_based_on =
+ (message && message.pick_serial_and_batch_based_on) || "FIFO";
+ }
+
+ return erpnext.stock._pick_serial_batch_based_on;
+};
diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
index 4b38b5a5633..5dd6d3d6d5c 100644
--- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
+++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
@@ -86,6 +86,8 @@
"serial_and_batch_bundle",
"use_serial_batch_fields",
"column_break_eaoe",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html",
"section_break_qyjv",
"serial_no",
"column_break_rxvc",
@@ -923,6 +925,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_qyjv",
@@ -971,7 +982,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2026-06-08 20:00:00.000000",
+ "modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note Item",
diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json
index 2bf4112c1a3..0a8944580c3 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.json
+++ b/erpnext/stock/doctype/packed_item/packed_item.json
@@ -26,6 +26,8 @@
"use_serial_batch_fields",
"column_break_11",
"serial_and_batch_bundle",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html",
"section_break_bgys",
"serial_no",
"column_break_qlha",
@@ -298,6 +300,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
{
"depends_on": "eval:doc.use_serial_batch_fields === 1 && !['Sales Order', 'Quotation'].includes(parent.doctype)",
"fieldname": "section_break_bgys",
@@ -338,7 +349,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2026-06-08 15:00:00.000000",
+ "modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packed Item",
diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json
index 658dff42d7f..50713795fd0 100644
--- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json
+++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json
@@ -32,6 +32,8 @@
"serial_and_batch_bundle",
"use_serial_batch_fields",
"column_break_20",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html",
"section_break_ecxc",
"serial_no",
"column_break_belw",
@@ -237,6 +239,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_ecxc",
@@ -296,7 +307,7 @@
],
"istable": 1,
"links": [],
- "modified": "2026-07-01 14:27:50.617011",
+ "modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List Item",
diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
index 6409e05724b..ce445d75470 100644
--- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
+++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
@@ -101,6 +101,10 @@
"col_break5",
"add_serial_batch_for_rejected_qty",
"rejected_serial_and_batch_bundle",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html",
+ "rejected_serial_batch_entries_section",
+ "rejected_serial_batch_entries_html",
"section_break_3vxt",
"serial_no",
"rejected_serial_no",
@@ -1117,12 +1121,30 @@
"no_copy": 1,
"print_hide": 1,
"read_only": 1
+ },
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
+ {
+ "fieldname": "rejected_serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Rejected Serial / Batch Entries"
+ },
+ {
+ "fieldname": "rejected_serial_batch_entries_html",
+ "fieldtype": "HTML"
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2026-06-08 21:00:00.000000",
+ "modified": "2026-07-16 15:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt Item",
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/inline_editor.py b/erpnext/stock/doctype/serial_and_batch_bundle/inline_editor.py
new file mode 100644
index 00000000000..433c2bf0016
--- /dev/null
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/inline_editor.py
@@ -0,0 +1,221 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.query_builder.functions import Count, Sum
+from frappe.utils import cint, flt, parse_json
+
+from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
+ create_serial_batch_no_ledgers,
+ get_type_of_transaction,
+ make_batch_nos,
+ make_serial_nos,
+)
+
+SUPPORTED_VOUCHER_TYPES = frozenset(
+ [
+ "Purchase Receipt",
+ "Purchase Invoice",
+ "Sales Invoice",
+ "POS Invoice",
+ "Delivery Note",
+ "Stock Entry",
+ "Stock Reconciliation",
+ "Subcontracting Receipt",
+ "Pick List",
+ "Asset Capitalization",
+ "Asset Repair",
+ ]
+)
+
+
+@frappe.whitelist()
+def get_bundle_entries(bundle: str, start: int = 0, page_length: int = 50, search: str | None = None):
+ frappe.has_permission("Serial and Batch Bundle", "read", doc=bundle, throw=True)
+ page_length = min(cint(page_length) or 50, 500)
+
+ table = frappe.qb.DocType("Serial and Batch Entry")
+ query = (
+ frappe.qb.from_(table)
+ .select(table.name, table.serial_no, table.batch_no, table.qty)
+ .where(table.parent == bundle)
+ .orderby(table.idx)
+ .limit(page_length)
+ .offset(cint(start))
+ )
+
+ if search:
+ search_term = f"%{search}%"
+ query = query.where((table.serial_no.like(search_term)) | (table.batch_no.like(search_term)))
+
+ entries = query.run(as_dict=True)
+ summary = get_bundle_summary(bundle)
+ summary["entries"] = entries
+
+ return summary
+
+
+def get_bundle_summary(bundle):
+ table = frappe.qb.DocType("Serial and Batch Entry")
+ row = (
+ frappe.qb.from_(table)
+ .select(Count(table.name).as_("total_count"), Sum(table.qty).as_("total_qty"))
+ .where(table.parent == bundle)
+ ).run(as_dict=True)[0]
+
+ return frappe._dict(
+ {
+ "bundle": bundle,
+ "total_count": cint(row.total_count),
+ "total_qty": abs(flt(row.total_qty)),
+ }
+ )
+
+
+@frappe.whitelist()
+def download_bundle_entries_csv(bundle: str):
+ from frappe.utils.csvutils import build_csv_response
+
+ frappe.has_permission("Serial and Batch Bundle", "read", doc=bundle, throw=True)
+ doc = frappe.get_doc("Serial and Batch Bundle", bundle)
+ item = frappe.get_cached_value("Item", doc.item_code, ["has_serial_no", "has_batch_no"], as_dict=True)
+
+ rows = [get_csv_columns(item)]
+ for entry in doc.entries:
+ if item.has_serial_no and item.has_batch_no:
+ rows.append([entry.serial_no, entry.batch_no, abs(entry.qty)])
+ elif item.has_batch_no:
+ rows.append([entry.batch_no, abs(entry.qty)])
+ else:
+ rows.append([entry.serial_no])
+
+ build_csv_response(rows, f"{bundle}-entries")
+
+
+def get_csv_columns(item):
+ if item.has_serial_no and item.has_batch_no:
+ return ["Serial No", "Batch No", "Quantity"]
+
+ if item.has_batch_no:
+ return ["Batch No", "Quantity"]
+
+ return ["Serial No"]
+
+
+@frappe.whitelist(methods=["POST"])
+def upsert_bundle_entries(
+ child_row: dict | str,
+ doc: dict | str,
+ entries: list | str | None = None,
+ deleted: list | str | None = None,
+ replace: int = 0,
+):
+ child_row = parse_json(child_row)
+ doc = parse_json(doc)
+ entries = parse_json(entries) or []
+ deleted = parse_json(deleted) or []
+
+ validate_parent_document(child_row, doc)
+
+ bundle_field = (
+ "rejected_serial_and_batch_bundle" if child_row.get("is_rejected") else "serial_and_batch_bundle"
+ )
+ bundle_name = child_row.get(bundle_field)
+ if bundle_name and frappe.db.exists("Serial and Batch Bundle", bundle_name):
+ bundle = apply_incremental_changes(bundle_name, child_row, entries, deleted, cint(replace))
+ if not bundle.entries:
+ remove_empty_bundle(bundle, child_row, bundle_field)
+ return frappe._dict({"bundle": None, "total_count": 0, "total_qty": 0})
+ else:
+ if not entries:
+ frappe.throw(_("Please add at least one Serial No or Batch to save"))
+
+ frappe.has_permission(doc.get("doctype"), "write", throw=True)
+ if get_type_of_transaction(doc, child_row) == "Inward":
+ make_serial_nos(child_row.item_code, entries)
+ make_batch_nos(child_row.item_code, entries)
+
+ bundle = create_serial_batch_no_ledgers(entries, child_row, doc)
+
+ return get_bundle_summary(bundle.name)
+
+
+def validate_parent_document(child_row, doc):
+ if doc.get("doctype") not in SUPPORTED_VOUCHER_TYPES:
+ frappe.throw(
+ _("{0} is not supported for the inline Serial / Batch editor").format(doc.get("doctype"))
+ )
+
+ if child_row.get("parenttype") != doc.get("doctype"):
+ frappe.throw(_("The selected row does not belong to the {0}").format(doc.get("doctype")))
+
+
+def remove_empty_bundle(bundle, child_row, bundle_field):
+ child_doctype, child_name = child_row.get("doctype"), child_row.get("name")
+ if (
+ child_name
+ and child_doctype
+ and frappe.get_meta(child_doctype).has_field(bundle_field)
+ and frappe.db.exists(child_doctype, {"name": child_name, bundle_field: bundle.name})
+ ):
+ frappe.db.set_value(child_doctype, child_name, bundle_field, None)
+
+ bundle.delete(ignore_permissions=True)
+
+
+def apply_incremental_changes(bundle_name, child_row, entries, deleted, replace=0):
+ frappe.has_permission("Serial and Batch Bundle", "write", doc=bundle_name, throw=True)
+ bundle = frappe.get_doc("Serial and Batch Bundle", bundle_name)
+
+ if bundle.docstatus == 1:
+ frappe.throw(
+ _("Serial and Batch Bundle {0} is submitted and its entries cannot be modified.").format(
+ frappe.bold(bundle_name)
+ )
+ )
+
+ sign = 1 if bundle.type_of_transaction == "Inward" else -1
+
+ if replace:
+ bundle.set("entries", [])
+ deleted = []
+ entries = [{key: value for key, value in row.items() if key != "name"} for row in entries]
+
+ if deleted:
+ bundle.entries = [d for d in bundle.entries if d.name not in deleted]
+
+ existing = {d.name: d for d in bundle.entries}
+ new_rows = [frappe._dict(row) for row in entries if not row.get("name")]
+
+ for row in entries:
+ if row.get("name") and row["name"] in existing:
+ entry = existing[row["name"]]
+ if row.get("qty") is not None:
+ entry.qty = (flt(row.get("qty")) or 1.0) * sign
+ if row.get("batch_no"):
+ entry.batch_no = row.get("batch_no")
+ if row.get("serial_no"):
+ entry.serial_no = row.get("serial_no")
+
+ if entries and bundle.type_of_transaction == "Inward":
+ incoming = [frappe._dict(row) for row in entries]
+ make_serial_nos(child_row.item_code, incoming)
+ make_batch_nos(child_row.item_code, incoming)
+
+ for row in new_rows:
+ bundle.append(
+ "entries",
+ {
+ "qty": (flt(row.qty) or 1.0) * sign,
+ "warehouse": bundle.warehouse,
+ "batch_no": row.batch_no,
+ "serial_no": row.serial_no,
+ },
+ )
+
+ if not bundle.entries:
+ return bundle
+
+ bundle.save(ignore_permissions=True)
+ return bundle
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 fe671b32801..1269dcb46dd 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
@@ -3011,6 +3011,9 @@ def get_auto_batch_nos(kwargs):
picked_batches,
)
+ if not kwargs.ignore_reserved_stock and not kwargs.for_stock_levels:
+ available_batches = remove_reservation_conflict_batches(available_batches, kwargs)
+
if kwargs.based_on == "Expiry":
available_batches = sorted(available_batches, key=lambda x: x.expiry_date or getdate("9999-12-31"))
@@ -3029,6 +3032,71 @@ def get_auto_batch_nos(kwargs):
return get_qty_based_available_batches(available_batches, qty)
+def remove_reservation_conflict_batches(available_batches, kwargs):
+ if not available_batches or not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
+ return available_batches
+
+ conflicting_batches = get_cross_warehouse_reserved_batches(kwargs)
+ if not conflicting_batches:
+ return available_batches
+
+ return [d for d in available_batches if d.batch_no not in conflicting_batches]
+
+
+def get_cross_warehouse_reserved_batches(kwargs) -> set:
+ from erpnext.stock.doctype.batch.batch import get_batch_qty
+
+ conflicting_batches = set()
+ for row in get_cross_warehouse_sre_details(kwargs):
+ if flt(row.outstanding_qty) <= 0:
+ continue
+
+ batch_qty = get_batch_qty(
+ row.batch_no,
+ row.warehouse,
+ posting_date=kwargs.get("posting_date"),
+ posting_time=kwargs.get("posting_time"),
+ consider_negative_batches=True,
+ )
+
+ if flt(batch_qty, 6) < flt(row.outstanding_qty, 6):
+ conflicting_batches.add(row.batch_no)
+
+ return conflicting_batches
+
+
+def get_cross_warehouse_sre_details(kwargs):
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ sb_entry = frappe.qb.DocType("Serial and Batch Entry")
+ query = (
+ frappe.qb.from_(sre)
+ .inner_join(sb_entry)
+ .on(sre.name == sb_entry.parent)
+ .select(
+ sb_entry.batch_no,
+ sre.warehouse,
+ Sum(sb_entry.qty - sb_entry.delivered_qty).as_("outstanding_qty"),
+ )
+ .where(
+ (sre.docstatus == 1)
+ & (sre.item_code == kwargs.item_code)
+ & (sre.delivered_qty < sre.reserved_qty)
+ & (sre.reservation_based_on == "Serial and Batch")
+ & (sb_entry.batch_no.isnotnull())
+ )
+ .groupby(sb_entry.batch_no, sre.warehouse)
+ )
+
+ if kwargs.get("company"):
+ query = query.where(sre.company == kwargs.get("company"))
+
+ if kwargs.warehouse:
+ warehouses = kwargs.warehouse if isinstance(kwargs.warehouse, list) else [kwargs.warehouse]
+ query = query.where(sre.warehouse.notin(warehouses))
+
+ return query.run(as_dict=True)
+
+
def get_batch_nos_from_sre(kwargs):
from frappe.query_builder.functions import Sum
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_inline_editor.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_inline_editor.py
new file mode 100644
index 00000000000..9c2743aa36e
--- /dev/null
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_inline_editor.py
@@ -0,0 +1,346 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import json
+
+import frappe
+
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+from erpnext.stock.doctype.serial_and_batch_bundle.inline_editor import (
+ get_bundle_entries,
+ upsert_bundle_entries,
+)
+from erpnext.tests.utils import ERPNextTestSuite
+
+
+class TestSerialBatchInlineEditor(ERPNextTestSuite):
+ def make_draft_pr(self, item_code, qty=2):
+ return make_purchase_receipt(item_code=item_code, qty=qty, rate=100, do_not_submit=True)
+
+ def upsert(self, pr, entries=None, deleted=None, is_rejected=0, replace=0):
+ child_row = pr.items[0].as_dict()
+ child_row["is_rejected"] = is_rejected
+
+ return upsert_bundle_entries(
+ child_row=json.dumps(child_row, default=str),
+ doc=json.dumps(pr.as_dict(), default=str),
+ entries=json.dumps(entries or []),
+ deleted=json.dumps(deleted or []),
+ replace=replace,
+ )
+
+ def reload_row(self, pr):
+ pr.reload()
+ return pr.items[0]
+
+ def test_create_bundle_with_serials(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item)
+ serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
+
+ summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
+
+ self.assertTrue(frappe.db.exists("Serial and Batch Bundle", summary.bundle))
+ self.assertEqual(summary.total_count, 2)
+ self.assertEqual(summary.total_qty, 2)
+ for serial_no in serials:
+ self.assertTrue(frappe.db.exists("Serial No", serial_no))
+
+ def test_incremental_append_preserves_existing_entries(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item, qty=3)
+ serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(3)]
+
+ summary = self.upsert(pr, entries=[{"serial_no": serials[0]}, {"serial_no": serials[1]}])
+ pr.items[0].serial_and_batch_bundle = summary.bundle
+ first_entry_names = set(
+ frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")
+ )
+
+ summary = self.upsert(pr, entries=[{"serial_no": serials[2]}])
+ second_entry_names = set(
+ frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")
+ )
+
+ self.assertEqual(summary.total_count, 3)
+ self.assertTrue(first_entry_names.issubset(second_entry_names))
+
+ def test_delete_entries(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item)
+ serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
+
+ summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
+ pr.items[0].serial_and_batch_bundle = summary.bundle
+
+ to_delete = frappe.get_all(
+ "Serial and Batch Entry", {"parent": summary.bundle, "serial_no": serials[0]}, pluck="name"
+ )
+ summary = self.upsert(pr, deleted=to_delete)
+
+ self.assertEqual(summary.total_count, 1)
+ remaining = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="serial_no")
+ self.assertEqual(remaining, [serials[1]])
+
+ def test_batch_qty_update(self):
+ item = make_item(
+ properties={
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "TSTBIE-.####",
+ }
+ ).name
+ pr = self.make_draft_pr(item, qty=5)
+ batch = frappe.get_doc(doctype="Batch", item=item).insert()
+
+ summary = self.upsert(pr, entries=[{"batch_no": batch.name, "qty": 5}])
+ pr.items[0].serial_and_batch_bundle = summary.bundle
+ self.assertEqual(summary.total_qty, 5)
+
+ entry_name = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")[0]
+ summary = self.upsert(pr, entries=[{"name": entry_name, "qty": 8}])
+
+ self.assertEqual(summary.total_qty, 8)
+ self.assertEqual(summary.total_count, 1)
+
+ def test_update_serial_no_of_existing_entry(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item, qty=1)
+ old_serial = f"SN-{frappe.generate_hash(length=8)}"
+ new_serial = f"SN-{frappe.generate_hash(length=8)}"
+
+ summary = self.upsert(pr, entries=[{"serial_no": old_serial}])
+ pr.items[0].serial_and_batch_bundle = summary.bundle
+ entry_name = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")[0]
+
+ self.upsert(pr, entries=[{"name": entry_name, "serial_no": new_serial}])
+
+ self.assertEqual(frappe.db.get_value("Serial and Batch Entry", entry_name, "serial_no"), new_serial)
+ self.assertTrue(frappe.db.exists("Serial No", new_serial))
+
+ def test_auto_create_missing_batch_no(self):
+ item = make_item(properties={"is_stock_item": 1, "has_batch_no": 1}).name
+ pr = self.make_draft_pr(item, qty=5)
+ batch1 = f"BNEW-{frappe.generate_hash(length=8)}"
+ batch2 = f"BNEW-{frappe.generate_hash(length=8)}"
+
+ self.assertFalse(frappe.db.exists("Batch", batch1))
+ summary = self.upsert(pr, entries=[{"batch_no": batch1, "qty": 4}])
+ self.assertTrue(frappe.db.exists("Batch", batch1))
+
+ pr.items[0].serial_and_batch_bundle = summary.bundle
+ summary = self.upsert(pr, entries=[{"batch_no": batch2, "qty": 1}])
+
+ self.assertTrue(frappe.db.exists("Batch", batch2))
+ self.assertEqual(summary.total_qty, 5)
+
+ def test_update_batch_no_of_existing_entry(self):
+ item = make_item(
+ properties={
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "TSTBIE-.####",
+ }
+ ).name
+ pr = self.make_draft_pr(item, qty=5)
+ batch1 = frappe.get_doc(doctype="Batch", item=item).insert()
+ batch2 = frappe.get_doc(doctype="Batch", item=item).insert()
+
+ summary = self.upsert(pr, entries=[{"batch_no": batch1.name, "qty": 5}])
+ pr.items[0].serial_and_batch_bundle = summary.bundle
+
+ entry_name = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")[0]
+ self.upsert(pr, entries=[{"name": entry_name, "batch_no": batch2.name}])
+
+ entry = frappe.db.get_value("Serial and Batch Entry", entry_name, ["batch_no", "qty"], as_dict=1)
+ self.assertEqual(entry.batch_no, batch2.name)
+ self.assertEqual(entry.qty, 5)
+
+ def test_delete_all_entries_removes_bundle(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item)
+ serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
+
+ summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
+ bundle = summary.bundle
+ pr.items[0].serial_and_batch_bundle = bundle
+ pr.items[0].db_set("serial_and_batch_bundle", bundle)
+
+ to_delete = frappe.get_all("Serial and Batch Entry", {"parent": bundle}, pluck="name")
+ summary = self.upsert(pr, deleted=to_delete)
+
+ self.assertFalse(summary.bundle)
+ self.assertEqual(summary.total_count, 0)
+ self.assertFalse(frappe.db.exists("Serial and Batch Bundle", bundle))
+ self.assertFalse(
+ frappe.db.get_value("Purchase Receipt Item", pr.items[0].name, "serial_and_batch_bundle")
+ )
+
+ def test_remove_empty_bundle_ignores_spoofed_child_row(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item)
+ victim_pr = self.make_draft_pr(item)
+
+ summary = self.upsert(pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}])
+ bundle = summary.bundle
+ pr.items[0].db_set("serial_and_batch_bundle", bundle)
+
+ victim_summary = self.upsert(
+ victim_pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}]
+ )
+ victim_bundle = victim_summary.bundle
+ victim_pr.items[0].db_set("serial_and_batch_bundle", victim_bundle)
+
+ child_row = pr.items[0].as_dict()
+ child_row["is_rejected"] = 0
+ child_row["name"] = victim_pr.items[0].name
+
+ to_delete = frappe.get_all("Serial and Batch Entry", {"parent": bundle}, pluck="name")
+ upsert_bundle_entries(
+ child_row=json.dumps(child_row, default=str),
+ doc=json.dumps(pr.as_dict(), default=str),
+ deleted=json.dumps(to_delete),
+ )
+
+ self.assertFalse(frappe.db.exists("Serial and Batch Bundle", bundle))
+ self.assertEqual(
+ frappe.db.get_value("Purchase Receipt Item", victim_pr.items[0].name, "serial_and_batch_bundle"),
+ victim_bundle,
+ )
+
+ def test_pagination(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item, qty=5)
+ serials = sorted(f"SN-{frappe.generate_hash(length=8)}" for _ in range(5))
+
+ summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
+
+ page = get_bundle_entries(summary.bundle, start=0, page_length=2)
+ self.assertEqual(len(page["entries"]), 2)
+ self.assertEqual(page["total_count"], 5)
+
+ last_page = get_bundle_entries(summary.bundle, start=4, page_length=2)
+ self.assertEqual(len(last_page["entries"]), 1)
+
+ def test_search_entries(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item)
+ token = frappe.generate_hash(length=8)
+ serials = [f"AAA-{token}", f"BBB-{token}"]
+
+ summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
+
+ page = get_bundle_entries(summary.bundle, search=f"AAA-{token}")
+ self.assertEqual(len(page["entries"]), 1)
+ self.assertEqual(page["entries"][0].serial_no, f"AAA-{token}")
+
+ def test_rejected_bundle_created_separately(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item)
+ pr.items[0].rejected_warehouse = "_Test Warehouse 1 - _TC"
+
+ accepted = self.upsert(pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}])
+ pr.items[0].serial_and_batch_bundle = accepted.bundle
+
+ rejected = self.upsert(
+ pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}], is_rejected=1
+ )
+
+ self.assertNotEqual(accepted.bundle, rejected.bundle)
+ bundle = frappe.get_doc("Serial and Batch Bundle", rejected.bundle)
+ self.assertEqual(bundle.is_rejected, 1)
+ self.assertEqual(bundle.warehouse, "_Test Warehouse 1 - _TC")
+
+ def test_replace_entries(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item, qty=3)
+ old_serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
+ new_serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(3)]
+
+ summary = self.upsert(pr, entries=[{"serial_no": d} for d in old_serials])
+ pr.items[0].serial_and_batch_bundle = summary.bundle
+
+ summary = self.upsert(pr, entries=[{"serial_no": d} for d in new_serials], replace=1)
+
+ self.assertEqual(summary.total_count, 3)
+ remaining = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="serial_no")
+ self.assertEqual(sorted(remaining), sorted(new_serials))
+
+ def test_replace_with_no_entries_removes_bundle(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item)
+ serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
+
+ summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
+ bundle = summary.bundle
+ pr.items[0].serial_and_batch_bundle = bundle
+
+ summary = self.upsert(pr, entries=[], replace=1)
+
+ self.assertFalse(summary.bundle)
+ self.assertEqual(summary.total_count, 0)
+ self.assertFalse(frappe.db.exists("Serial and Batch Bundle", bundle))
+
+ def test_create_bundle_for_stock_entry(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ se = make_stock_entry(item_code=item, qty=2, to_warehouse="_Test Warehouse - _TC", do_not_submit=True)
+
+ child_row = se.items[0].as_dict()
+ child_row["is_rejected"] = 0
+ summary = upsert_bundle_entries(
+ child_row=json.dumps(child_row, default=str),
+ doc=json.dumps(se.as_dict(), default=str),
+ entries=json.dumps([{"serial_no": f"SN-{frappe.generate_hash(length=8)}"} for _ in range(2)]),
+ deleted=json.dumps([]),
+ )
+
+ bundle = frappe.get_doc("Serial and Batch Bundle", summary.bundle)
+ self.assertEqual(bundle.voucher_type, "Stock Entry")
+ self.assertEqual(bundle.type_of_transaction, "Inward")
+ self.assertEqual(summary.total_qty, 2)
+
+ def test_upsert_requires_entries_for_new_bundle(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item)
+
+ self.assertRaises(frappe.ValidationError, self.upsert, pr)
+
+ def test_upsert_rejects_mismatched_parenttype(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item)
+
+ child_row = pr.items[0].as_dict()
+ child_row["is_rejected"] = 0
+ child_row["parenttype"] = "Task"
+
+ self.assertRaises(
+ frappe.ValidationError,
+ upsert_bundle_entries,
+ child_row=json.dumps(child_row, default=str),
+ doc=json.dumps(pr.as_dict(), default=str),
+ entries=json.dumps([{"serial_no": "SBIE-PT-0001"}]),
+ )
+
+ def test_upsert_rejects_unsupported_voucher_type(self):
+ item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+ pr = self.make_draft_pr(item)
+
+ child_row = pr.items[0].as_dict()
+ child_row["is_rejected"] = 0
+ child_row["parenttype"] = "Task"
+
+ doc = pr.as_dict()
+ doc["doctype"] = "Task"
+
+ self.assertRaises(
+ frappe.ValidationError,
+ upsert_bundle_entries,
+ child_row=json.dumps(child_row, default=str),
+ doc=json.dumps(doc, default=str),
+ entries=json.dumps([{"serial_no": "SBIE-PT-0002"}]),
+ )
diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
index 167be4af85b..126daf21389 100644
--- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
+++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
@@ -54,6 +54,8 @@
"use_serial_batch_fields",
"col_break4",
"serial_and_batch_bundle",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html",
"section_break_rdtg",
"serial_no",
"column_break_prps",
@@ -615,6 +617,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_rdtg",
@@ -689,7 +700,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2026-07-03 12:11:53.714931",
+ "modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",
diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
index 4013049476b..3515666b690 100644
--- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
+++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
@@ -25,6 +25,8 @@
"column_break_11",
"serial_and_batch_bundle",
"current_serial_and_batch_bundle",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html",
"section_break_lypk",
"serial_no",
"column_break_eefq",
@@ -246,6 +248,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_lypk",
@@ -266,7 +277,7 @@
"grid_page_length": 50,
"istable": 1,
"links": [],
- "modified": "2025-11-20 15:27:13.868179",
+ "modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reconciliation Item",
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index 48981955052..bdd06893828 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.json
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.json
@@ -47,6 +47,7 @@
"pick_serial_and_batch_based_on",
"allow_existing_serial_no",
"use_serial_batch_fields",
+ "use_inline_serial_batch_editor",
"disable_serial_no_and_batch_selector",
"section_break_gnhq",
"allow_negative_stock_for_batch",
@@ -595,6 +596,14 @@
{
"fieldname": "section_break_kcvr",
"fieldtype": "Section Break"
+ },
+ {
+ "default": "1",
+ "depends_on": "eval:!doc.use_serial_batch_fields",
+ "description": "Show an inline editable table for serial numbers / batches on the item row instead of the dialog",
+ "fieldname": "use_inline_serial_batch_editor",
+ "fieldtype": "Check",
+ "label": "Use Inline Serial / Batch Editor"
}
],
"icon": "icon-cog",
@@ -602,7 +611,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2026-06-26 10:00:00.000000",
+ "modified": "2026-07-16 17:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py
index 139c2f26851..557c3a1d901 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.py
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.py
@@ -66,6 +66,7 @@ class StockSettings(Document):
stock_uom: DF.Link | None
update_existing_price_list_rate: DF.Check
update_price_list_based_on: DF.Literal["Rate", "Price List Rate"]
+ use_inline_serial_batch_editor: DF.Check
use_naming_series: DF.Check
use_serial_batch_fields: DF.Check
validate_material_transfer_warehouses: DF.Check
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json
index 71f262d7663..4a0f1176c69 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json
@@ -57,6 +57,10 @@
"col_break5",
"add_serial_batch_for_rejected_qty",
"rejected_serial_and_batch_bundle",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html",
+ "rejected_serial_batch_entries_section",
+ "rejected_serial_batch_entries_html",
"section_break_jshh",
"serial_no",
"rejected_serial_no",
@@ -548,6 +552,24 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
+ {
+ "fieldname": "rejected_serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Rejected Serial / Batch Entries"
+ },
+ {
+ "fieldname": "rejected_serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_jshh",
@@ -635,7 +657,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2026-06-01 10:00:00.000000",
+ "modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Receipt Item",
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.json b/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.json
index ce3494e879d..8d26da40863 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.json
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt_supplied_item/subcontracting_receipt_supplied_item.json
@@ -30,6 +30,8 @@
"use_serial_batch_fields",
"col_break4",
"subcontracting_order",
+ "serial_batch_entries_section",
+ "serial_batch_entries_html",
"section_break_zwnh",
"serial_no",
"column_break_qibi",
@@ -221,6 +223,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
+ {
+ "fieldname": "serial_batch_entries_section",
+ "fieldtype": "Section Break",
+ "label": "Serial / Batch Entries"
+ },
+ {
+ "fieldname": "serial_batch_entries_html",
+ "fieldtype": "HTML"
+ },
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_zwnh",
@@ -264,7 +275,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2025-05-27 12:33:58.772638",
+ "modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Receipt Supplied Item",