From 92b6d708d88c49a552efcf198f678e36322e683e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 9 Sep 2026 07:11:49 +0530 Subject: [PATCH] fix(stock): reuse transaction serial and batch fields --- .../pos_invoice_item/pos_invoice_item.json | 20 --- .../purchase_invoice_item.json | 30 ---- .../sales_invoice_item.json | 20 --- .../asset_capitalization_stock_item.json | 20 --- .../asset_repair_consumed_item.json | 10 -- .../purchase_receipt_item_supplied.json | 20 --- erpnext/hooks.py | 8 +- .../maintenance_schedule_detail.json | 10 -- .../maintenance_schedule_item.json | 10 -- erpnext/public/js/utils/barcode_scanner.js | 57 +++++--- erpnext/public/js/utils/serial_batch_input.js | 72 ++++++++-- .../installation_note_item.json | 10 -- .../delivery_note_item.json | 20 --- .../doctype/packed_item/packed_item.json | 20 --- .../pick_list_item/pick_list_item.json | 20 --- .../purchase_receipt_item.json | 30 ---- .../stock_entry_detail.json | 20 --- .../stock_reconciliation_item.json | 30 ---- erpnext/stock/serial_batch_display.py | 6 + erpnext/stock/serial_batch_import.py | 96 +++++++++++++ erpnext/stock/serial_batch_input.py | 21 ++- .../stock/tests/test_serial_batch_identity.py | 10 ++ .../test_serial_batch_identity_access.py | 3 +- .../stock/tests/test_serial_batch_input.py | 135 +++++++++++++++--- .../tests/test_serial_batch_number_lookup.py | 3 +- erpnext/stock/utils.py | 5 + .../subcontracting_receipt_item.json | 30 ---- .../subcontracting_receipt_supplied_item.json | 20 --- erpnext/utilities/transaction_base.py | 6 + 29 files changed, 359 insertions(+), 403 deletions(-) create mode 100644 erpnext/stock/serial_batch_import.py 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 2bcb2954d3b..ca73391bfb4 100644 --- a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json +++ b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json @@ -93,10 +93,8 @@ "serial_batch_entries_html", "section_break_tlhi", "serial_no", - "serial_number", "column_break_ciit", "batch_no", - "batch_number", "edit_references", "sales_order", "so_detail", @@ -887,24 +885,6 @@ "fieldtype": "Currency", "label": "Distributed Discount Amount", "options": "currency" - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "istable": 1, 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 a7936c6b8fa..9153209ff56 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -81,12 +81,9 @@ "rejected_serial_batch_entries_html", "section_break_rqbe", "serial_no", - "serial_number", "rejected_serial_no", - "rejected_serial_number", "column_break_vbbb", "batch_no", - "batch_number", "manufacture_details", "manufacturer", "column_break_13", @@ -1030,33 +1027,6 @@ "label": "Delivered by Supplier", "print_hide": 1, "read_only": 1 - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "rejected_serial_number", - "fieldtype": "Small Text", - "label": "Rejected Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "grid_page_length": 50, 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 dcb0561399e..8cbd6a3a5bd 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -98,10 +98,8 @@ "serial_batch_entries_html", "section_break_eoec", "serial_no", - "serial_number", "column_break_ytgd", "batch_no", - "batch_number", "available_quantity_section", "actual_qty", "column_break_ogff", @@ -1064,24 +1062,6 @@ "label": "Pick List Item", "no_copy": 1, "read_only": 1 - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "grid_page_length": 50, 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 726b373dbe5..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 @@ -25,10 +25,8 @@ "serial_batch_entries_html", "section_break_bfqc", "serial_no", - "serial_number", "column_break_mbuv", "batch_no", - "batch_number", "accounting_dimensions_section", "cost_center", "dimension_col_break" @@ -192,24 +190,6 @@ "fieldtype": "Data", "hidden": 1, "label": "Purchase Receipt Item" - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "grid_page_length": 50, 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 2b7dd4ad2fb..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 @@ -11,7 +11,6 @@ "consumed_quantity", "total_value", "serial_no", - "serial_number", "column_break_xzfr", "pick_serial_and_batch", "serial_and_batch_bundle", @@ -84,15 +83,6 @@ { "fieldname": "serial_batch_entries_html", "fieldtype": "HTML" - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "index_web_pages_for_search": 1, diff --git a/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.json b/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.json index 3206df5b242..90a48b12801 100644 --- a/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.json +++ b/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.json @@ -25,10 +25,8 @@ "current_stock", "secbreak_3", "batch_no", - "batch_number", "col_break4", "serial_no", - "serial_number", "purchase_order" ], "fields": [ @@ -205,24 +203,6 @@ "options": "Purchase Order", "print_hide": 1, "read_only": 1 - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "idx": 1, diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 08f8f1cb213..48d2c0ab733 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -385,14 +385,14 @@ pre_submit_validation_doctypes = [ ] -extend_doctype_class = { - doctype: "erpnext.stock.serial_batch_display.SerialNumberDisplay" for doctype in SERIAL_TEXT_DOCTYPES -} +extend_doctype_class.update( + {doctype: "erpnext.stock.serial_batch_display.SerialNumberDisplay" for doctype in SERIAL_TEXT_DOCTYPES} +) +extend_doctype_class["Data Import"] = "erpnext.stock.serial_batch_import.SerialBatchDataImport" doc_events = { "*": { "before_print": "erpnext.stock.serial_batch_display.before_print", - "before_validate": "erpnext.stock.serial_batch_input.resolve_transaction_numbers", "validate": [ "erpnext.support.doctype.service_level_agreement.service_level_agreement.apply", "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.check_for_running_deletion_job", diff --git a/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json b/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json index a856229fd02..c63ff65e81e 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json +++ b/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json @@ -18,7 +18,6 @@ "completion_status", "section_break_10", "serial_no", - "serial_number", "item_reference" ], "fields": [ @@ -122,15 +121,6 @@ "label": "Item Reference", "options": "Maintenance Schedule Item", "read_only": 1 - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "idx": 1, diff --git a/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json b/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json index 7d550aa7a53..75d63ab50a6 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json +++ b/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json @@ -20,7 +20,6 @@ "sales_person", "reference", "serial_no", - "serial_number", "sales_order", "column_break_ugqr", "serial_and_batch_bundle" @@ -160,15 +159,6 @@ "no_copy": 1, "options": "Serial and Batch Bundle", "print_hide": 1 - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "idx": 1, diff --git a/erpnext/public/js/utils/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js index 758f0a8659e..43e1bf1d622 100644 --- a/erpnext/public/js/utils/barcode_scanner.js +++ b/erpnext/public/js/utils/barcode_scanner.js @@ -168,6 +168,8 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { for (const match of r.message?.candidates || [r.message || {}]) { if (match.serial_no && match.serial_number) frappe.utils.add_link_title("Serial No", match.serial_no, match.serial_number); + if (match.batch_no && match.batch_number) + frappe.utils.add_link_title("Batch", match.batch_no, match.batch_number); } callback(r); }); @@ -183,6 +185,16 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { frappe.flags.trigger_from_barcode_scanner = true; const { item_code, barcode, batch_no, serial_no, uom, default_warehouse } = data; + if ( + serial_no && + (this.frm.doc[this.items_table_name] || []).some( + (row) => row.item_code === item_code && this.is_duplicate_serial_no(row, serial_no) + ) + ) { + this.clean_up(); + reject(); + return; + } let row = this.get_row_to_modify_on_scan(item_code, batch_no, uom, barcode, default_warehouse); const is_new_row = !row?.item_code; if (!row) { @@ -200,12 +212,6 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { this.frm.has_items = false; } - if (this.is_duplicate_serial_no(row, serial_no)) { - this.clean_up(); - reject(); - return; - } - frappe.run_serially([ () => this.set_selector_trigger_flag(data), () => this.set_barcode(row, barcode), @@ -245,11 +251,14 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { set_item(row, item_code, barcode, batch_no, serial_no) { return new Promise((resolve) => { const increment = async (value = 1) => { + const existing = erpnext.serial_batch_input.is_pending(row, this.serial_no_field) + ? "" + : row[this.serial_no_field]; const item_data = this.get_scanned_item_values( row, item_code, batch_no, - serial_no ? this.merge_serial_nos(row[this.serial_no_field], serial_no) : null + serial_no ? this.merge_serial_nos(existing, serial_no) : null ); frappe.flags.trigger_from_barcode_scanner = true; item_data[this.qty_field] = @@ -274,19 +283,20 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { // Item selection must receive the scanned references before it can auto-pick stock. const values = { item_code, use_serial_batch_fields: 1 }; if (serial_no && frappe.meta.has_field(row.doctype, this.serial_no_field)) { - if (row.serial_number != null && this.serial_no_field === "serial_no") { + if (erpnext.serial_batch_input.is_pending(row, this.serial_no_field)) { const numbers = serial_no .split("\n") .map((id) => frappe.utils.get_link_title("Serial No", id) || id) .join("\n"); - values.serial_number = this.merge_serial_nos(row.serial_number, numbers); + values[this.serial_no_field] = this.merge_serial_nos(row[this.serial_no_field], numbers); + erpnext.serial_batch_input.mark(row, this.serial_no_field, values[this.serial_no_field]); } else { values[this.serial_no_field] = serial_no; } } if (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) { values[this.batch_no_field] = batch_no; - if (frappe.meta.has_field(row.doctype, "batch_number")) values.batch_number = null; + erpnext.serial_batch_input.clear(row, this.batch_no_field); } return values; } @@ -497,14 +507,11 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { async set_serial_no(row, serial_no) { if (serial_no && frappe.meta.has_field(row.doctype, this.serial_no_field)) { - if (row.serial_number != null && this.serial_no_field === "serial_no") { + if (erpnext.serial_batch_input.is_pending(row, this.serial_no_field)) { const number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no; - await frappe.model.set_value( - row.doctype, - row.name, - "serial_number", - this.merge_serial_nos(row.serial_number, number) - ); + const merged = this.merge_serial_nos(row[this.serial_no_field], number); + erpnext.serial_batch_input.mark(row, this.serial_no_field, merged); + await frappe.model.set_value(row.doctype, row.name, this.serial_no_field, merged); return; } const new_serial_nos = this.merge_serial_nos(row[this.serial_no_field], serial_no); @@ -528,6 +535,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { } async set_batch_no(row, batch_no) { + erpnext.serial_batch_input.clear(row, this.batch_no_field); if (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) { await frappe.model.set_value(row.doctype, row.name, this.batch_no_field, batch_no); } @@ -562,9 +570,11 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { is_duplicate_serial_no(row, serial_no) { const physical_number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no; - const pending_duplicate = row.serial_number - ?.split("\n") - .some((number) => number.toUpperCase() === physical_number?.toUpperCase()); + const pending_duplicate = + erpnext.serial_batch_input.is_pending(row, this.serial_no_field) && + row[this.serial_no_field] + ?.split("\n") + .some((number) => number.toUpperCase() === physical_number?.toUpperCase()); const is_duplicate = serial_no && (pending_duplicate || row[this.serial_no_field]?.split("\n").includes(serial_no)); @@ -591,7 +601,12 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { const matching_row = (row) => { const item_match = row.item_code == item_code; - const batch_match = !row[this.batch_no_field] || row[this.batch_no_field] == batch_no; + const batch_match = + !row[this.batch_no_field] || + (erpnext.serial_batch_input.is_pending(row, this.batch_no_field) + ? row[this.batch_no_field].toUpperCase() === + frappe.utils.get_link_title("Batch", batch_no)?.toUpperCase() + : row[this.batch_no_field] === batch_no); const uom_match = !uom || this.max_qty_field || row[this.uom_field] == uom; const has_demand_qty = this.demand_ref_fields.some((fieldname) => row[fieldname]); const qty_in_limit = !has_demand_qty || flt(row[this.qty_field]) < flt(row[this.max_qty_field]); diff --git a/erpnext/public/js/utils/serial_batch_input.js b/erpnext/public/js/utils/serial_batch_input.js index fb8732f0f2b..0218ae8b3bb 100644 --- a/erpnext/public/js/utils/serial_batch_input.js +++ b/erpnext/public/js/utils/serial_batch_input.js @@ -1,5 +1,6 @@ // Physical input remains pending until the transaction is saved. const registered_forms = new Set(); +const pending_values = new WeakMap(); const serial_list_fields = new Set(["serial_no", "rejected_serial_no", "current_serial_no"]); const with_serial_numbers = (BaseControl) => @@ -33,8 +34,7 @@ const with_serial_numbers = (BaseControl) => return super.parse_validate_and_set_in_model(value, event); } const context = this.number_context(); - const number_field = this.df.fieldname.replace("_no", "_number"); - if (frappe.meta.has_field(context.row.doctype, number_field)) { + if (context.row.parenttype && context.row.doctype !== "Stock Ledger Entry") { const numbers = split_physical_numbers(value); await set_pending_number(context, this.df.fieldname, numbers.join("\n")); return; @@ -71,8 +71,7 @@ const with_serial_numbers = (BaseControl) => serial_number_text(value) { const { row } = this.number_context(); - const pending = row?.[this.df.fieldname.replace("_no", "_number")]; - if (pending != null) return pending; + if (erpnext.serial_batch_input.is_pending(row, this.df.fieldname)) return value || ""; return (value || "") .split("\n") .map((id) => frappe.utils.get_link_title("Serial No", id) || id) @@ -80,6 +79,7 @@ const with_serial_numbers = (BaseControl) => } async load_serial_titles(value) { + if (erpnext.serial_batch_input.is_pending(this.number_context().row, this.df.fieldname)) return; const missing = (value || "") .split("\n") .filter((id) => id && !frappe.utils.get_link_title("Serial No", id)); @@ -143,12 +143,15 @@ frappe.ui.form.ControlLink = class extends frappe.ui.form.ControlLink { if ( doctype === "Batch" && this.df.fieldname === "batch_no" && - frappe.meta.has_field(row.doctype, "batch_number") + row.parenttype && + ["Small Text", "Text", "Long Text"].includes( + frappe.meta.get_docfield(row.doctype, "serial_no")?.fieldtype + ) ) { await set_pending_number({ frm, row }, "batch_no", (label ?? this.get_label_value()).trim()); return; } - if (doctype === "Batch" && label !== undefined) row.batch_number = null; + if (label !== undefined) erpnext.serial_batch_input.clear(row, this.df.fieldname); // Autocomplete supplies the selected physical label; change/blur supplies typed text. const number = (label ?? this.get_label_value()).trim(); @@ -185,8 +188,8 @@ frappe.ui.form.ControlLink = class extends frappe.ui.form.ControlLink { set_formatted_input(value) { super.set_formatted_input(value); const { row } = this.serial_batch_context || { row: this.doc }; - if (this.df.fieldname === "batch_no" && row?.batch_number != null) { - this.$input?.val(row.batch_number); + if (this.df.fieldname === "batch_no" && erpnext.serial_batch_input.is_pending(row, "batch_no")) { + this.$input?.val(value); } } }; @@ -199,13 +202,17 @@ function split_physical_numbers(value) { } async function set_pending_number({ frm, row }, field, value) { - const values = { [field]: "", [field.replace("_no", "_number")]: value }; + erpnext.serial_batch_input.mark(row, field, value); + row[field] = value; + frm.dirty(); + frm.refresh_field(row.parentfield || field); + const values = {}; if (frappe.meta.has_field(row.doctype, "use_serial_batch_fields")) values.use_serial_batch_fields = 1; if (frappe.meta.has_field(row.doctype, "serial_and_batch_bundle")) values.serial_and_batch_bundle = ""; const pending = (async () => { await frappe.model.set_value(row.doctype, row.name, values); const numbers = split_physical_numbers(value); - if (field === "serial_no" && numbers.length && !frm.doc.is_return && row.serial_number === value) { + if (field === "serial_no" && numbers.length && !frm.doc.is_return && row.serial_no === value) { await frappe.model.set_value( row.doctype, row.name, @@ -225,9 +232,50 @@ async function set_pending_number({ frm, row }, field, value) { function track_number_request(frm, pending) { if (!registered_forms.has(frm.doctype)) { registered_forms.add(frm.doctype); - const wait = (form) => Promise.all([...(form.serial_number_requests || [])]); - frappe.ui.form.on(frm.doctype, { validate: wait, before_save: wait }); + const wait = async (form) => { + await Promise.all([...(form.serial_number_requests || [])]); + for (const row of frappe.model.get_all_docs(form.doc)) { + for (const field of [...(row.__serial_batch_input || [])]) { + erpnext.serial_batch_input.is_pending(row, field); + } + } + }; + frappe.ui.form.on(frm.doctype, { + validate: wait, + before_save: wait, + after_save(form) { + for (const row of frappe.model.get_all_docs(form.doc)) { + delete row.__serial_batch_input; + pending_values.delete(row); + } + }, + }); } frm.serial_number_requests ||= new Set(); frm.serial_number_requests.add(pending); } + +erpnext.serial_batch_input = { + mark(row, field, value) { + row.__serial_batch_input = [...new Set([...(row.__serial_batch_input || []), field])]; + const inputs = pending_values.get(row) || {}; + inputs[field] = value; + pending_values.set(row, inputs); + }, + is_pending(row, field) { + if (!row?.__serial_batch_input?.includes(field)) return false; + const inputs = pending_values.get(row); + if (inputs && field in inputs && inputs[field] !== row[field]) { + this.clear(row, field); + return false; + } + return true; + }, + clear(row, field) { + if (!row?.__serial_batch_input) return; + row.__serial_batch_input = row.__serial_batch_input.filter((name) => name !== field); + if (!row.__serial_batch_input.length) delete row.__serial_batch_input; + const inputs = pending_values.get(row); + if (inputs) delete inputs[field]; + }, +}; diff --git a/erpnext/selling/doctype/installation_note_item/installation_note_item.json b/erpnext/selling/doctype/installation_note_item/installation_note_item.json index 3a73e163b23..0b3b07dd429 100644 --- a/erpnext/selling/doctype/installation_note_item/installation_note_item.json +++ b/erpnext/selling/doctype/installation_note_item/installation_note_item.json @@ -9,7 +9,6 @@ "item_code", "serial_and_batch_bundle", "serial_no", - "serial_number", "qty", "description", "prevdoc_detail_docname", @@ -108,15 +107,6 @@ "no_copy": 1, "options": "Serial and Batch Bundle", "print_hide": 1 - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "idx": 1, 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 923a3da87c9..1125d2cc226 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -91,10 +91,8 @@ "serial_batch_entries_html", "section_break_qyjv", "serial_no", - "serial_number", "column_break_rxvc", "batch_no", - "batch_number", "available_qty_section", "actual_qty", "actual_batch_qty", @@ -988,24 +986,6 @@ "print_hide": 1, "read_only": 1, "search_index": 1 - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "grid_page_length": 50, diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index 89098d7903a..62e70aa3c16 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -30,10 +30,8 @@ "serial_batch_entries_html", "section_break_bgys", "serial_no", - "serial_number", "column_break_qlha", "batch_no", - "batch_number", "actual_batch_qty", "section_break_13", "actual_qty", @@ -346,24 +344,6 @@ "no_copy": 1, "print_hide": 1, "report_hide": 1 - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "idx": 1, 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 100a7ae379c..d391667cc17 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json @@ -36,10 +36,8 @@ "serial_batch_entries_html", "section_break_ecxc", "serial_no", - "serial_number", "column_break_belw", "batch_no", - "batch_number", "column_break_15", "sales_order", "sales_order_item", @@ -306,24 +304,6 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "istable": 1, 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 6956949b711..996632d17a7 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -108,12 +108,9 @@ "rejected_serial_batch_entries_html", "section_break_3vxt", "serial_no", - "serial_number", "rejected_serial_no", - "rejected_serial_number", "column_break_tolu", "batch_no", - "batch_number", "item_weight_details", "weight_per_unit", "total_weight", @@ -1153,33 +1150,6 @@ { "fieldname": "rejected_serial_batch_entries_html", "fieldtype": "HTML" - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "rejected_serial_number", - "fieldtype": "Small Text", - "label": "Rejected Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "idx": 1, 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 80cc7a9149c..c4bed814be6 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -58,10 +58,8 @@ "serial_batch_entries_html", "section_break_rdtg", "serial_no", - "serial_number", "column_break_prps", "batch_no", - "batch_number", "accounting", "expense_account", "accounting_dimensions_section", @@ -697,24 +695,6 @@ "label": "Valuation Type", "options": "\nValuation Rate\n% of FG Cost\nManual", "read_only_depends_on": "eval:!doc.secondary_item_type || doc.bom_secondary_item" - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "grid_page_length": 50, 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 9bd89adc7c4..3515666b690 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json @@ -29,17 +29,14 @@ "serial_batch_entries_html", "section_break_lypk", "serial_no", - "serial_number", "column_break_eefq", "batch_no", - "batch_number", "section_break_3", "current_qty", "current_amount", "column_break_9", "current_valuation_rate", "current_serial_no", - "current_serial_number", "section_break_14", "quantity_difference", "column_break_16", @@ -275,33 +272,6 @@ "fieldname": "reconcile_all_serial_batch", "fieldtype": "Check", "label": "Reconcile All Serial Nos / Batches" - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "current_serial_number", - "fieldtype": "Small Text", - "label": "Current Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "grid_page_length": 50, diff --git a/erpnext/stock/serial_batch_display.py b/erpnext/stock/serial_batch_display.py index 67df7733268..5bdc76502e6 100644 --- a/erpnext/stock/serial_batch_display.py +++ b/erpnext/stock/serial_batch_display.py @@ -81,6 +81,12 @@ def before_print(doc, method=None, print_settings=None, **kwargs): class SerialNumberDisplay: + def as_dict(self, *args, **kwargs): + doc = super().as_dict(*args, **kwargs) + if self.get("__serial_batch_input") and not kwargs.get("no_private_properties"): + doc["__serial_batch_input"] = self.get("__serial_batch_input").copy() + return doc + def get_formatted(self, fieldname, *args, **kwargs): field = self.meta.get_field(fieldname) if ( diff --git a/erpnext/stock/serial_batch_import.py b/erpnext/stock/serial_batch_import.py new file mode 100644 index 00000000000..00d5f9653f9 --- /dev/null +++ b/erpnext/stock/serial_batch_import.py @@ -0,0 +1,96 @@ +from copy import copy + +import frappe +from frappe import _ +from frappe.core.doctype.data_import.importer import Importer, Row +from frappe.utils.background_jobs import is_job_enqueued +from frappe.utils.scheduler import is_scheduler_inactive +from rq.timeouts import JobTimeoutException + +from erpnext.stock.serial_batch_fields import NUMBER_INPUT_DOCTYPES +from erpnext.stock.serial_batch_input import NUMBER_FIELDS + + +class SerialBatchDataImport: + def get_importer(self): + if not has_number_inputs(self.reference_doctype): + return super().get_importer() + return SerialBatchImporter(self.reference_doctype, data_import=self, use_sniffer=self.use_csv_sniffer) + + def start_import(self): + if not has_number_inputs(self.reference_doctype): + return super().start_import() + run_now = frappe.in_test or frappe.conf.developer_mode + if is_scheduler_inactive() and not run_now: + frappe.throw(_("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive")) + job_id = f"data_import||{self.name}" + if not is_job_enqueued(job_id): + frappe.enqueue( + start_import, + queue="default", + timeout=10000, + event="data_import", + job_id=job_id, + data_import=self.name, + now=run_now, + enqueue_after_commit=True, + ) + return True + + +def start_import(data_import): + data_import = frappe.get_doc("Data Import", data_import) + data_import.set_delimiters_flag() + try: + data_import.get_importer().import_data() + except JobTimeoutException: + frappe.db.rollback() + data_import.db_set("status", "Timed Out") + except Exception: + frappe.db.rollback() + data_import.db_set("status", "Error") + data_import.log_error("Data import failed") + finally: + frappe.flags.in_import = False + frappe.publish_realtime( + "data_import_refresh", {"data_import": data_import.name}, user=frappe.session.user + ) + + +class SerialBatchImporter(Importer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.template_options.get("serial_batch_input") is False: + return + for column in self.import_file.header.columns: + df = column.df + if column.skip_import or not df or df.parent not in NUMBER_INPUT_DOCTYPES: + continue + if df.fieldname == "batch_no": + # Import cells contain physical numbers; validate their links after item resolution. + column.df = copy(df) + column.df.fieldtype = "Data" + column.warnings = [warning for warning in column.warnings if warning.get("type") == "info"] + column.invalid_value_items = None + self.import_file.data = [ + SerialBatchImportRow(row.index, row.data, row.doctype, row.header, row.import_type) + for row in self.import_file.data + ] + + +class SerialBatchImportRow(Row): + def _parse_doc(self, doctype, columns, values, parent_doc=None, table_df=None): + doc = super()._parse_doc(doctype, columns, values, parent_doc, table_df) + if doctype in NUMBER_INPUT_DOCTYPES: + fields = [ + column.df.fieldname + for column, value in zip(columns, values, strict=True) + if column.df.fieldname in NUMBER_FIELDS and value not in (None, "") + ] + if fields: + doc.update({"__serial_batch_input": fields}) + return doc + + +def has_number_inputs(doctype): + return any(df.options in NUMBER_INPUT_DOCTYPES for df in frappe.get_meta(doctype).get_table_fields()) diff --git a/erpnext/stock/serial_batch_input.py b/erpnext/stock/serial_batch_input.py index 8520742777e..4d5d3621e69 100644 --- a/erpnext/stock/serial_batch_input.py +++ b/erpnext/stock/serial_batch_input.py @@ -4,6 +4,8 @@ from frappe import _ from erpnext.stock.serial_batch_fields import NUMBER_INPUT_DOCTYPES from erpnext.stock.serial_batch_identity import SerialBatchIdentity +NUMBER_FIELDS = ("batch_no", "serial_no", "rejected_serial_no", "current_serial_no") + def resolve_transaction_numbers(doc, method=None): if doc.docstatus == 2: @@ -20,11 +22,17 @@ class TransactionNumberInput: self.item_code = row.get("item_code") or row.get("rm_item_code") def resolve(self): - for field in ("batch_no", "serial_no", "rejected_serial_no", "current_serial_no"): - number_field = field.replace("_no", "_number") - value = self.row.get(number_field) - if value is None: + fields = self.row.get("__serial_batch_input") + if fields is None: + return + if not isinstance(fields, list) or any(field not in NUMBER_FIELDS for field in fields): + frappe.throw(_("Physical input must identify serial or batch fields")) + for field in NUMBER_FIELDS: + if field not in fields: continue + value = self.row.get(field) + if value is None: + value = "" if not isinstance(value, str): frappe.throw(_("Physical numbers must be text")) numbers = ( @@ -34,13 +42,12 @@ class TransactionNumberInput: ) if field == "batch_no" and len(numbers) > 1: frappe.throw(_("Enter one physical batch number per row")) - if self.row.get(field): - frappe.throw(_("Provide either {0} or {1}, not both").format(field, number_field)) names = self.resolve_numbers(field, numbers) if numbers else [] self.row.set(field, "\n".join(names)) - self.row.set(number_field, None) + fields.remove(field) if names and self.row.meta.has_field("use_serial_batch_fields"): self.row.use_serial_batch_fields = 1 + self.row.__dict__.pop("__serial_batch_input", None) def resolve_numbers(self, field, numbers): doctype = "Batch" if field == "batch_no" else "Serial No" diff --git a/erpnext/stock/tests/test_serial_batch_identity.py b/erpnext/stock/tests/test_serial_batch_identity.py index 33877399427..467e7887070 100644 --- a/erpnext/stock/tests/test_serial_batch_identity.py +++ b/erpnext/stock/tests/test_serial_batch_identity.py @@ -77,6 +77,16 @@ class TestSerialBatchIdentity(ERPNextTestSuite): matches = scan_barcode(number, allow_multiple=True)["candidates"] self.assertEqual({row.item_code for row in matches}, {item.name for item in items}) + def test_serial_scan_includes_the_physical_batch_number(self): + item = make_item(properties={"has_serial_no": 1, "has_batch_no": 1}) + batch = SerialBatchIdentity("Batch").resolve(item.name, ["Scanned-Batch"], create=True)[0] + serial = SerialBatchIdentity("Serial No").resolve( + item.name, ["Scanned-Serial"], create=True, defaults={"batch_no": batch} + )[0] + match = scan_barcode("Scanned-Serial", {"item_code": item.name}) + self.assertEqual((match.serial_no, match.batch_no), (serial, batch)) + self.assertEqual((match.serial_number, match.batch_number), ("Scanned-Serial", "Scanned-Batch")) + def test_receipts_store_ids_and_display_numbers(self): for serialized in (False, True): number = frappe.generate_hash() diff --git a/erpnext/stock/tests/test_serial_batch_identity_access.py b/erpnext/stock/tests/test_serial_batch_identity_access.py index 1484cb03dc2..2e0dd9d3519 100644 --- a/erpnext/stock/tests/test_serial_batch_identity_access.py +++ b/erpnext/stock/tests/test_serial_batch_identity_access.py @@ -63,7 +63,8 @@ class TestSerialBatchIdentityAccess(ERPNextTestSuite): item = make_item(properties={"has_serial_no": 1}) user = self.make_stock_user() receipt = make_purchase_receipt(item_code=item.name, qty=1, do_not_save=True) - receipt.items[0].serial_number = "UNAUTHORIZED-ON-SAVE" + receipt.items[0].serial_no = "UNAUTHORIZED-ON-SAVE" + receipt.items[0].set("__serial_batch_input", ["serial_no"]) with self.set_user(user.name): with self.assertRaises(frappe.PermissionError): receipt.insert() diff --git a/erpnext/stock/tests/test_serial_batch_input.py b/erpnext/stock/tests/test_serial_batch_input.py index 70fb0928bda..a0ae1329bb0 100644 --- a/erpnext/stock/tests/test_serial_batch_input.py +++ b/erpnext/stock/tests/test_serial_batch_input.py @@ -21,11 +21,12 @@ class TestSerialBatchInput(ERPNextTestSuite): for _ in range(2): receipt = self.make_receipt() row = receipt.items[0] - row.serial_number, row.batch_number = "Physical-Serial", "Physical-Batch" + row.serial_no, row.batch_no = "Physical-Serial", "Physical-Batch" + row.set("__serial_batch_input", ["serial_no", "batch_no"]) self.assertFalse(frappe.db.exists("Serial No", {"item_code": row.item_code})) receipt.insert() - self.assertFalse(row.serial_number) - self.assertFalse(row.batch_number) + self.assertFalse(row.get("__serial_batch_input")) + self.assertNotIn("__serial_batch_input", receipt.as_dict()["items"][0]) self.assertNotEqual(row.serial_no, "Physical-Serial") self.assertNotEqual(row.batch_no, "Physical-Batch") self.assertEqual(frappe.get_doc("Serial No", row.serial_no).status, "Inactive") @@ -40,7 +41,8 @@ class TestSerialBatchInput(ERPNextTestSuite): def test_failed_save_rolls_back_number_creation(self): receipt = self.make_receipt() row = receipt.items[0] - row.serial_number, row.batch_number = "Rollback-Serial", "Rollback-Batch" + row.serial_no, row.batch_no = "Rollback-Serial", "Rollback-Batch" + row.set("__serial_batch_input", ["serial_no", "batch_no"]) frappe.db.savepoint("failed_physical_input") try: with patch.object(type(receipt), "validate", side_effect=frappe.ValidationError): @@ -92,18 +94,61 @@ class TestSerialBatchInput(ERPNextTestSuite): def test_duplicate_physical_serials_are_rejected(self): receipt = self.make_receipt(has_batch_no=0) - receipt.items[0].serial_number = "Same-Serial\nSAME-SERIAL" + receipt.items[0].serial_no = "Same-Serial\nSAME-SERIAL" + receipt.items[0].set("__serial_batch_input", ["serial_no"]) with self.assertRaises(frappe.ValidationError): receipt.insert() - def test_internal_id_and_physical_input_cannot_conflict(self): + def test_physical_input_never_falls_back_to_an_internal_id(self): receipt = self.make_receipt(has_batch_no=0) row = receipt.items[0] + identity = SerialBatchIdentity("Serial No") + original = identity.resolve(row.item_code, ["One"], create=True)[0] + other = identity.resolve(row.item_code, [original], create=True)[0] + row.serial_no = original + row.set("__serial_batch_input", ["serial_no"]) + receipt.insert() + self.assertEqual(row.serial_no, other) + receipt.save() + self.assertEqual(row.serial_no, other) + receipt.reload() + self.assertEqual(row.serial_no, other) + + def test_unmarked_fields_preserve_internal_ids(self): + receipt = self.make_receipt() + row = receipt.items[0] row.serial_no = SerialBatchIdentity("Serial No").resolve(row.item_code, ["One"], create=True)[0] - row.serial_number = "Another" + row.batch_no = SerialBatchIdentity("Batch").resolve(row.item_code, ["One"], create=True)[0] + ids = row.serial_no, row.batch_no + receipt.insert() + self.assertEqual((row.serial_no, row.batch_no), ids) + + def test_request_metadata_survives_serialization_until_save(self): + receipt = self.make_receipt() + row = receipt.items[0] + row.serial_no, row.batch_no = "API-Serial", "API-Batch" + row.set("__serial_batch_input", ["serial_no", "batch_no"]) + payload = frappe.parse_json(receipt.as_json()) + self.assertEqual(payload["items"][0]["__serial_batch_input"], ["serial_no", "batch_no"]) + saved = frappe.get_doc(payload).insert() + self.assertEqual(frappe.get_doc("Serial No", saved.items[0].serial_no).serial_no, "API-Serial") + self.assertEqual(frappe.get_doc("Batch", saved.items[0].batch_no).batch_id, "API-Batch") + self.assertNotIn("__serial_batch_input", saved.as_dict()["items"][0]) + + def test_input_metadata_cannot_target_other_fields(self): + receipt = self.make_receipt() + receipt.items[0].set("__serial_batch_input", ["item_code"]) with self.assertRaises(frappe.ValidationError): receipt.insert() + def test_no_extra_transaction_number_fields(self): + from erpnext.stock.serial_batch_fields import NUMBER_INPUT_DOCTYPES + + for doctype in NUMBER_INPUT_DOCTYPES: + meta = frappe.get_meta(doctype) + for field in ("serial_number", "batch_number", "rejected_serial_number", "current_serial_number"): + self.assertFalse(meta.has_field(field), (doctype, field)) + def test_bundle_save_resolves_physical_entries(self): from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( add_serial_batch_ledgers, @@ -137,8 +182,37 @@ class TestSerialBatchInput(ERPNextTestSuite): self.assertEqual(entries[0]["batch_no"], batch) self.assertEqual(entries[1]["batch_no"], other_batch) - def test_data_import_accepts_explicit_physical_number_columns(self): - from frappe.core.doctype.data_import.importer import ImportFile + def test_data_import_can_explicitly_preserve_internal_ids(self): + from erpnext.stock.serial_batch_import import SerialBatchImporter + + receipt = self.make_receipt() + item = receipt.items[0].item_code + serial = SerialBatchIdentity("Serial No").resolve(item, ["Exported-Serial"], create=True)[0] + batch = SerialBatchIdentity("Batch").resolve(item, ["Exported-Batch"], create=True)[0] + with TemporaryDirectory() as directory: + path = Path(directory) / "internal_ids.csv" + with path.open("w", newline="") as file: + writer = csv.writer(file) + writer.writerow( + ["supplier", "company", "items.item_code", "items.serial_no", "items.batch_no"] + ) + writer.writerow([receipt.supplier, receipt.company, item, serial, batch]) + importer = SerialBatchImporter( + "Purchase Receipt", + file_path=str(path), + console=True, + data_import=frappe.get_doc( + doctype="Data Import", + import_type="Insert New Records", + template_options=frappe.as_json({"column_to_field_map": {}, "serial_batch_input": False}), + ), + ) + row = importer.import_file.get_payloads_for_import()[0].doc["items"][0] + self.assertFalse(row.get("__serial_batch_input")) + self.assertEqual((row.serial_no, row.batch_no), (serial, batch)) + + def test_data_import_reuses_existing_number_columns(self): + from erpnext.stock.serial_batch_import import SerialBatchImporter receipt = self.make_receipt() with TemporaryDirectory() as directory: @@ -153,8 +227,8 @@ class TestSerialBatchInput(ERPNextTestSuite): "items.qty", "items.rate", "items.warehouse", - "items.serial_number", - "items.batch_number", + "items.serial_no", + "items.batch_no", ] ) writer.writerow( @@ -169,11 +243,38 @@ class TestSerialBatchInput(ERPNextTestSuite): "Imported-Batch", ] ) - payloads = ImportFile( - "Purchase Receipt", str(path), import_type="Insert New Records", console=True - ).get_payloads_for_import() + importer = SerialBatchImporter( + "Purchase Receipt", + file_path=str(path), + import_type="Insert New Records", + console=True, + data_import=frappe.get_doc(doctype="Data Import", import_type="Insert New Records"), + ) + payloads = importer.import_file.get_payloads_for_import() + self.assertFalse(importer.import_file.get_all_warnings()) + file = frappe.get_doc( + doctype="File", file_name="physical_numbers.csv", content=path.read_text(), is_private=1 + ).insert() + self.addCleanup(frappe.delete_doc, "File", file.name) self.assertEqual(len(payloads), 1) - imported = frappe.new_doc("Purchase Receipt").update(payloads[0].doc).insert() + data_import = frappe.get_doc( + doctype="Data Import", + reference_doctype="Purchase Receipt", + import_type="Insert New Records", + import_file=file.file_url, + submit_after_import=1, + ).insert() + self.assertIsInstance(data_import.get_importer(), SerialBatchImporter) + with patch.object(frappe.db, "commit"): + data_import.start_import() + self.assertEqual(data_import.reload().status, "Success") + imported = frappe.get_doc( + "Purchase Receipt", + frappe.db.get_value("Data Import Log", {"data_import": data_import.name}, "docname"), + ) + self.assertEqual(imported.docstatus, 1) row = imported.items[0] - self.assertEqual(frappe.get_doc("Serial No", row.serial_no).serial_no, "Imported-Serial") - self.assertEqual(frappe.get_doc("Batch", row.batch_no).batch_id, "Imported-Batch") + entry = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle).entries[0] + self.assertEqual(frappe.get_doc("Serial No", entry.serial_no).serial_no, "Imported-Serial") + self.assertEqual(frappe.get_doc("Batch", entry.batch_no).batch_id, "Imported-Batch") + imported.cancel() diff --git a/erpnext/stock/tests/test_serial_batch_number_lookup.py b/erpnext/stock/tests/test_serial_batch_number_lookup.py index 8003874b308..b51511295d4 100644 --- a/erpnext/stock/tests/test_serial_batch_number_lookup.py +++ b/erpnext/stock/tests/test_serial_batch_number_lookup.py @@ -93,7 +93,8 @@ class TestSerialBatchNumberLookup(ERPNextTestSuite): names = identity.resolve(item.name, numbers, create=True) receipt = make_purchase_receipt(item_code=item.name, qty=50, do_not_save=True) row = receipt.items[0] - row.serial_number = "\n".join(number.lower() for number in numbers) + row.serial_no = "\n".join(number.lower() for number in numbers) + row.set("__serial_batch_input", ["serial_no"]) load = SerialBatchNumberLookup.load with patch.object(SerialBatchNumberLookup, "load", autospec=True, side_effect=load) as lookup: TransactionNumberInput(receipt, row).resolve() diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index ba107c79090..a2cb1826855 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -635,7 +635,12 @@ def scan_barcode(search_value: str, ctx: dict | str | None = None, allow_multipl .run(as_dict=True) ) + batch_labels = SerialBatchIdentity("Batch").labels( + [candidate.batch_no for candidate in candidates if candidate.get("batch_no")] + ) for candidate in candidates: + if candidate.get("batch_no"): + candidate.batch_number = batch_labels.get(candidate.batch_no, candidate.batch_no) _update_item_info(candidate, ctx) if len(candidates) > 1: 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 270c805d835..61dce1d4e7b 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json @@ -64,12 +64,9 @@ "rejected_serial_batch_entries_html", "section_break_jshh", "serial_no", - "serial_number", "rejected_serial_no", - "rejected_serial_number", "column_break_henr", "batch_no", - "batch_number", "manufacture_details", "manufacturer", "column_break_16", @@ -664,33 +661,6 @@ "fieldtype": "Float", "label": "Process Loss Qty", "non_negative": 1 - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "rejected_serial_number", - "fieldtype": "Small Text", - "label": "Rejected Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "grid_page_length": 50, 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 4e601079475..ec10fd07146 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 @@ -34,10 +34,8 @@ "serial_batch_entries_html", "section_break_zwnh", "serial_no", - "serial_number", "column_break_qibi", "batch_no", - "batch_number", "accounting_details_section", "expense_account", "accounting_dimensions_section", @@ -273,24 +271,6 @@ "label": "Cost Center", "options": "Cost Center", "print_hide": 1 - }, - { - "fieldname": "serial_number", - "fieldtype": "Small Text", - "label": "Serial No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." - }, - { - "fieldname": "batch_number", - "fieldtype": "Data", - "label": "Batch No (Physical Input)", - "hidden": 1, - "no_copy": 1, - "print_hide": 1, - "description": "Enter physical numbers. These are resolved for the selected Item when saving; reference fields store internal IDs." } ], "idx": 1, diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 3c6ad064d15..a4f74b86944 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -18,6 +18,12 @@ class UOMMustBeIntegerError(frappe.ValidationError): class TransactionBase(StatusUpdater): + def _validate_links(self): + from erpnext.stock.serial_batch_input import resolve_transaction_numbers + + resolve_transaction_numbers(self) + return super()._validate_links() + def on_change(self): # `on_change` also fires for `db_set()`, so only run during an actual insert/save. is_real_save = self.flags.in_insert or (self.doctype, self.name) in frappe.flags.currently_saving