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(`${header}${body}
`); + + 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",