From d81fe03776c41ba38c88ac7adf3d716a6864cac4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 8 Sep 2026 22:20:12 +0530 Subject: [PATCH] fix(stock): apply serial and batch identity review fixes --- .../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 | 22 ++- erpnext/hooks.py | 8 + .../maintenance_schedule_detail.json | 12 +- .../maintenance_schedule_item.json | 12 +- erpnext/patches.txt | 2 +- .../patches/separate_serial_batch_identity.py | 13 -- erpnext/patches/v17_0/__init__.py | 0 .../v17_0/separate_serial_batch_identity.py | 20 ++ erpnext/public/js/utils/barcode_scanner.js | 28 ++- erpnext/public/js/utils/serial_batch_input.js | 82 +++++++- .../js/utils/serial_no_batch_selector.js | 84 ++++++--- .../installation_note_item.json | 12 +- .../page/point_of_sale/point_of_sale.py | 1 + .../page/point_of_sale/pos_item_selector.js | 34 +++- erpnext/stock/doctype/batch/batch.json | 2 +- .../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 +++ .../serial_and_batch_bundle.py | 9 +- .../stock/doctype/serial_no/test_serial_no.py | 2 - .../stock_entry_detail.json | 20 ++ .../stock_reconciliation_item.json | 30 +++ .../stock/report/stock_ageing/stock_ageing.py | 6 +- .../report/stock_ageing/test_stock_ageing.py | 52 ++--- erpnext/stock/serial_batch_bundle.py | 52 +++-- erpnext/stock/serial_batch_display.py | 28 ++- erpnext/stock/serial_batch_fields.py | 22 +++ erpnext/stock/serial_batch_identity.py | 73 ++++++-- erpnext/stock/serial_batch_input.py | 83 ++++++++ .../stock/tests/test_serial_batch_identity.py | 8 +- .../test_serial_batch_identity_access.py | 66 +++++-- .../test_serial_batch_identity_matching.py | 2 +- .../stock/tests/test_serial_batch_input.py | 177 ++++++++++++++++++ .../subcontracting_receipt_item.json | 30 +++ .../subcontracting_receipt_supplied_item.json | 20 ++ 41 files changed, 1044 insertions(+), 158 deletions(-) delete mode 100644 erpnext/patches/separate_serial_batch_identity.py create mode 100644 erpnext/patches/v17_0/__init__.py create mode 100644 erpnext/patches/v17_0/separate_serial_batch_identity.py create mode 100644 erpnext/stock/serial_batch_fields.py create mode 100644 erpnext/stock/serial_batch_input.py create mode 100644 erpnext/stock/tests/test_serial_batch_input.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 ca73391bfb4..2bcb2954d3b 100644 --- a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json +++ b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json @@ -93,8 +93,10 @@ "serial_batch_entries_html", "section_break_tlhi", "serial_no", + "serial_number", "column_break_ciit", "batch_no", + "batch_number", "edit_references", "sales_order", "so_detail", @@ -885,6 +887,24 @@ "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 9153209ff56..a7936c6b8fa 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -81,9 +81,12 @@ "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", @@ -1027,6 +1030,33 @@ "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 8cbd6a3a5bd..dcb0561399e 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -98,8 +98,10 @@ "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", @@ -1062,6 +1064,24 @@ "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 7022d240a7a..726b373dbe5 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,8 +25,10 @@ "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" @@ -190,6 +192,24 @@ "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 bb2304ab50d..2b7dd4ad2fb 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,6 +11,7 @@ "consumed_quantity", "total_value", "serial_no", + "serial_number", "column_break_xzfr", "pick_serial_and_batch", "serial_and_batch_bundle", @@ -83,6 +84,15 @@ { "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 6ace8bddf39..3206df5b242 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,8 +25,10 @@ "current_stock", "secbreak_3", "batch_no", + "batch_number", "col_break4", "serial_no", + "serial_number", "purchase_order" ], "fields": [ @@ -203,6 +205,24 @@ "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, @@ -218,4 +238,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 3e1f714ddb1..08f8f1cb213 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -1,3 +1,5 @@ +from erpnext.stock.serial_batch_fields import SERIAL_TEXT_DOCTYPES + app_name = "erpnext" app_title = "ERPNext" app_publisher = "Frappe Technologies Pvt. Ltd." @@ -382,9 +384,15 @@ pre_submit_validation_doctypes = [ "Sales Order", ] + +extend_doctype_class = { + doctype: "erpnext.stock.serial_batch_display.SerialNumberDisplay" for doctype in SERIAL_TEXT_DOCTYPES +} + 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 c625bc3312c..a856229fd02 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json +++ b/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json @@ -18,6 +18,7 @@ "completion_status", "section_break_10", "serial_no", + "serial_number", "item_reference" ], "fields": [ @@ -121,6 +122,15 @@ "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, @@ -137,4 +147,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} 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 6a5da5e2646..7d550aa7a53 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json +++ b/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json @@ -20,6 +20,7 @@ "sales_person", "reference", "serial_no", + "serial_number", "sales_order", "column_break_ugqr", "serial_and_batch_bundle" @@ -159,6 +160,15 @@ "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, @@ -174,4 +184,4 @@ "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 44e6bbf0815..1f9675db456 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -262,7 +262,7 @@ erpnext.patches.v15_0.rename_subcontracting_fields erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage erpnext.patches.v16_0.convert_commission_rate_to_percent erpnext.patches.v16_0.convert_hide_currency_symbol_to_check -erpnext.patches.separate_serial_batch_identity +erpnext.patches.v17_0.separate_serial_batch_identity [post_model_sync] erpnext.patches.v15_0.rename_gross_purchase_amount_to_net_purchase_amount diff --git a/erpnext/patches/separate_serial_batch_identity.py b/erpnext/patches/separate_serial_batch_identity.py deleted file mode 100644 index f02611370a2..00000000000 --- a/erpnext/patches/separate_serial_batch_identity.py +++ /dev/null @@ -1,13 +0,0 @@ -import frappe - -from erpnext.stock.serial_batch_identity import SerialBatchIdentity - - -def execute(): - for doctype in ("Serial No", "Batch"): - SerialBatchIdentity(doctype).validate_existing_numbers() - - # Reload also drops the former single-field unique indexes on both database engines. - for doctype in ("Serial No", "Batch"): - frappe.reload_doc("stock", "doctype", frappe.scrub(doctype), force=True) - SerialBatchIdentity(doctype).sync_constraint() diff --git a/erpnext/patches/v17_0/__init__.py b/erpnext/patches/v17_0/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/patches/v17_0/separate_serial_batch_identity.py b/erpnext/patches/v17_0/separate_serial_batch_identity.py new file mode 100644 index 00000000000..cd35d14c325 --- /dev/null +++ b/erpnext/patches/v17_0/separate_serial_batch_identity.py @@ -0,0 +1,20 @@ +import frappe + +from erpnext.stock.serial_batch_identity import SerialBatchIdentity + + +def execute(): + checked = [] + for doctype in ("Serial No", "Batch"): + identity = SerialBatchIdentity(doctype) + if not identity.has_constraint(): + identity.validate_existing_numbers() + checked.append(doctype) + + previous = frappe.flags.serial_batch_preflight + try: + frappe.flags.serial_batch_preflight = checked + for doctype in ("Serial No", "Batch"): + frappe.reload_doc("stock", "doctype", frappe.scrub(doctype), force=True) + finally: + frappe.flags.serial_batch_preflight = previous diff --git a/erpnext/public/js/utils/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js index 43984c81310..758f0a8659e 100644 --- a/erpnext/public/js/utils/barcode_scanner.js +++ b/erpnext/public/js/utils/barcode_scanner.js @@ -274,10 +274,19 @@ 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)) { - values[this.serial_no_field] = serial_no; + if (row.serial_number != null && this.serial_no_field === "serial_no") { + 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); + } 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; } return values; } @@ -488,6 +497,16 @@ 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") { + 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) + ); + return; + } const new_serial_nos = this.merge_serial_nos(row[this.serial_no_field], serial_no); await frappe.model.set_value(row.doctype, row.name, this.serial_no_field, new_serial_nos); } @@ -542,7 +561,12 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { } is_duplicate_serial_no(row, serial_no) { - const is_duplicate = serial_no && row[this.serial_no_field]?.split("\n").includes(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 is_duplicate = + serial_no && (pending_duplicate || row[this.serial_no_field]?.split("\n").includes(serial_no)); if (is_duplicate) { const number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no; diff --git a/erpnext/public/js/utils/serial_batch_input.js b/erpnext/public/js/utils/serial_batch_input.js index f6e2db917bf..fb8732f0f2b 100644 --- a/erpnext/public/js/utils/serial_batch_input.js +++ b/erpnext/public/js/utils/serial_batch_input.js @@ -1,4 +1,4 @@ -// Resolve physical input before updating serial and batch links in the form model. +// Physical input remains pending until the transaction is saved. const registered_forms = new Set(); const serial_list_fields = new Set(["serial_no", "rejected_serial_no", "current_serial_no"]); @@ -18,11 +18,27 @@ const with_serial_numbers = (BaseControl) => ); } + bind_change_event() { + if (!this.frm || !serial_list_fields.has(this.df.fieldname) || this.df.parent === "Serial No") + return super.bind_change_event(); + this.$input.on("change", (event) => + this.parse_validate_and_set_in_model(this.get_input_value(), event) + ); + this.$input.on("input", () => this.number_context().frm.dirty()); + } + async parse_validate_and_set_in_model(value, event) { const revision = (this.number_revision = (this.number_revision || 0) + 1); if (!this.is_serial_list() || !event) { 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)) { + const numbers = split_physical_numbers(value); + await set_pending_number(context, this.df.fieldname, numbers.join("\n")); + return; + } const { frm, row } = this.number_context(); const item_code = row.item_code || row.rm_item_code; const pending = (async () => { @@ -30,12 +46,13 @@ const with_serial_numbers = (BaseControl) => .split(/[,\n]/) .map((number) => number.trim()) .filter(Boolean); - const ids = numbers.length - ? await frappe.xcall( - "erpnext.stock.serial_batch_identity.resolve_transaction_serial_numbers", - { parent: frm.doc, row, numbers } - ) - : []; + const result = numbers.length + ? await frappe.xcall("erpnext.stock.serial_batch_identity.resolve_serial_batch_numbers", { + item_code, + serial_numbers: numbers, + }) + : { serial_nos: [] }; + const ids = result.serial_nos; if (revision !== this.number_revision || item_code !== (row.item_code || row.rm_item_code)) return; ids.forEach((id, index) => frappe.utils.add_link_title("Serial No", id, numbers[index])); @@ -53,6 +70,9 @@ 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; return (value || "") .split("\n") .map((id) => frappe.utils.get_link_title("Serial No", id) || id) @@ -120,6 +140,16 @@ frappe.ui.form.ControlLink = class extends frappe.ui.form.ControlLink { return super.parse_validate_and_set_in_model(value, event, label); } + if ( + doctype === "Batch" && + this.df.fieldname === "batch_no" && + frappe.meta.has_field(row.doctype, "batch_number") + ) { + await set_pending_number({ frm, row }, "batch_no", (label ?? this.get_label_value()).trim()); + return; + } + if (doctype === "Batch" && label !== undefined) row.batch_number = null; + // Autocomplete supplies the selected physical label; change/blur supplies typed text. const number = (label ?? this.get_label_value()).trim(); const pending = (async () => { @@ -152,8 +182,46 @@ frappe.ui.form.ControlLink = class extends frappe.ui.form.ControlLink { frm.serial_number_requests.delete(pending); } } + 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); + } + } }; +function split_physical_numbers(value) { + return (value || "") + .split(/[,\n]/) + .map((number) => number.trim()) + .filter(Boolean); +} + +async function set_pending_number({ frm, row }, field, value) { + const values = { [field]: "", [field.replace("_no", "_number")]: value }; + 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) { + await frappe.model.set_value( + row.doctype, + row.name, + "qty", + numbers.length / (row.conversion_factor || 1) + ); + } + })(); + track_number_request(frm, pending); + try { + await pending; + } finally { + frm.serial_number_requests.delete(pending); + } +} + function track_number_request(frm, pending) { if (!registered_forms.has(frm.doctype)) { registered_forms.add(frm.doctype); diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 5db48458c95..c698fa27145 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -54,26 +54,19 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { qty = Math.abs(qty); if (qty > 0) { - this.dialog.set_value("qty", qty).then(() => { + this.dialog.set_value("qty", qty).then(async () => { if (this.item.serial_no && !this.item.serial_and_batch_bundle) { - let serial_nos = this.item.serial_no.split("\n"); - if (serial_nos.length > 1) { - serial_nos.forEach((serial_no) => { - this.dialog.fields_dict.entries.df.data.push({ - serial_no: serial_no, - batch_no: this.item.batch_no, - }); - }); - } else { - this.dialog.set_value("scan_serial_no", this.item.serial_no); - } + await this.set_data( + this.item.serial_no + .split("\n") + .filter(Boolean) + .map((serial_no) => ({ serial_no, batch_no: this.item.batch_no, qty: 1 })) + ); frappe.model.set_value(this.item.doctype, this.item.name, "serial_no", ""); } else if (this.item.batch_no && !this.item.serial_and_batch_bundle) { - this.dialog.set_value("scan_batch_no", this.item.batch_no); + await this.set_data([{ batch_no: this.item.batch_no, qty }]); frappe.model.set_value(this.item.doctype, this.item.name, "batch_no", ""); } - - this.dialog.fields_dict.entries.grid.refresh(); }); } } @@ -336,10 +329,10 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { item_code: this.item.item_code, serial_nos: upload_serial_nos, }, - callback: (r) => { + callback: async (r) => { if (r.message) { this.dialog.fields_dict.entries.df.data = []; - this.set_data(r.message); + await this.set_data(r.message); this.update_bundle_entries(); } }, @@ -522,6 +515,18 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { hidden: 1, }); + if (this.item.type_of_transaction === "Inward") { + for (const field of fields) { + if (!["serial_no", "batch_no"].includes(field.fieldname)) continue; + const reference = field.fieldname; + field.fieldtype = "Data"; + field.fieldname = reference.replace("_no", "_number"); + field.change = function () { + this.doc[reference] = null; + }; + } + } + return fields; } @@ -571,8 +576,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { }, callback: (r) => { if (r.message) { - this.dialog.fields_dict.entries.df.data = r.message; - this.dialog.fields_dict.entries.grid.refresh(); + this.dialog.fields_dict.entries.df.data = []; + this.set_data(r.message); } }, }); @@ -584,12 +589,32 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { this.dialog.set_value("enter_manually", 0); + if (this.item.type_of_transaction === "Inward") { + const entries = this.dialog.fields_dict.entries.df.data; + if ( + scan_serial_no && + entries.some((row) => row.serial_number?.toUpperCase() === scan_serial_no.toUpperCase()) + ) { + frappe.throw(__("Serial No {0} already exists", [scan_serial_no])); + } + if (scan_serial_no || scan_batch_no) { + const batch = + !scan_serial_no && + entries.find((row) => row.batch_number?.toUpperCase() === scan_batch_no.toUpperCase()); + if (batch) batch.qty = flt(batch.qty) + 1; + else entries.push({ serial_number: scan_serial_no, batch_number: scan_batch_no, qty: 1 }); + this.dialog.set_value("scan_serial_no", ""); + this.dialog.set_value("scan_batch_no", ""); + this.dialog.fields_dict.entries.grid.refresh(); + } + return; + } + if (scan_serial_no || scan_batch_no) { frappe.call({ - method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.is_serial_batch_no_exists", + method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.resolve_scanned_serial_batch_numbers", args: { item_code: this.item.item_code, - type_of_transaction: this.item.type_of_transaction, serial_no: scan_serial_no, batch_no: scan_batch_no, }, @@ -773,7 +798,22 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { } } - set_data(data) { + async set_data(data) { + if (this.item.type_of_transaction === "Inward") { + for (const [field, doctype] of [ + ["serial_no", "Serial No"], + ["batch_no", "Batch"], + ]) { + const names = data.map((row) => row[field]).filter(Boolean); + const labels = names.length + ? await frappe.xcall("erpnext.stock.serial_batch_identity.get_serial_batch_labels", { + doctype, + names, + }) + : {}; + for (const row of data) row[field.replace("_no", "_number")] ||= labels[row[field]]; + } + } data.forEach((d) => { d.qty = Math.abs(d.qty); d.name = d.child_row || d.name; 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 6438ca25c5f..3a73e163b23 100644 --- a/erpnext/selling/doctype/installation_note_item/installation_note_item.json +++ b/erpnext/selling/doctype/installation_note_item/installation_note_item.json @@ -9,6 +9,7 @@ "item_code", "serial_and_batch_bundle", "serial_no", + "serial_number", "qty", "description", "prevdoc_detail_docname", @@ -107,6 +108,15 @@ "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, @@ -123,4 +133,4 @@ "sort_order": "ASC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py index 6282b7ee812..a4da6f267e9 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -23,6 +23,7 @@ def search_by_term(search_term, warehouse, price_list): return { "items": [get_scanned_item(match, warehouse, price_list) for match in matches], "requires_selection": len(matches) > 1, + "is_scan": True, } diff --git a/erpnext/selling/page/point_of_sale/pos_item_selector.js b/erpnext/selling/page/point_of_sale/pos_item_selector.js index 46329031fd4..f6da6251a07 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_selector.js +++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js @@ -74,11 +74,34 @@ erpnext.PointOfSale.ItemSelector = class { const price_list = (doc && doc.selling_price_list) || this.price_list; let { item_group, pos_profile } = this; - return frappe.call({ - method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items", - freeze: true, - args: { start, page_length, price_list, item_group, search_term, pos_profile }, - }); + const cache_key = JSON.stringify([ + pos_profile, + price_list, + item_group, + start, + page_length, + search_term, + ]); + this.items_cache ||= new Map(); + const scanned = this.barcode_search_pending; + this.barcode_search_pending = false; + if (!scanned && this.items_cache.has(cache_key)) { + return $.Deferred() + .resolve({ message: this.items_cache.get(cache_key) }) + .promise(); + } + return frappe + .call({ + method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items", + freeze: true, + args: { start, page_length, price_list, item_group, search_term, pos_profile }, + }) + .then((response) => { + if (!scanned && !response.message?.is_scan && response.message?.items?.length) { + this.items_cache.set(cache_key, response.message); + } + return response; + }); } render_item_list(items) { @@ -347,6 +370,7 @@ erpnext.PointOfSale.ItemSelector = class { this.search_field.set_focus(); this.set_search_value(sScancode); this.barcode_scanned = true; + this.barcode_search_pending = true; } }, }); diff --git a/erpnext/stock/doctype/batch/batch.json b/erpnext/stock/doctype/batch/batch.json index 6d3faf4b4f3..402d57747c9 100644 --- a/erpnext/stock/doctype/batch/batch.json +++ b/erpnext/stock/doctype/batch/batch.json @@ -295,5 +295,5 @@ "title_field": "batch_id", "track_changes": 1, "show_title_field_in_link": 1, - "search_fields": "batch_id,item" + "search_fields": "item" } 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 1125d2cc226..923a3da87c9 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -91,8 +91,10 @@ "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", @@ -986,6 +988,24 @@ "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 62e70aa3c16..89098d7903a 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -30,8 +30,10 @@ "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", @@ -344,6 +346,24 @@ "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 d391667cc17..100a7ae379c 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json @@ -36,8 +36,10 @@ "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", @@ -304,6 +306,24 @@ "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 996632d17a7..6956949b711 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -108,9 +108,12 @@ "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", @@ -1150,6 +1153,33 @@ { "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/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index e237e00ba3e..c98a4f0bff0 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 @@ -2173,6 +2173,10 @@ def add_serial_batch_ledgers( if parent_doc and isinstance(parent_doc, str): parent_doc = parse_json(parent_doc) + resolve_number_entries( + child_row.item_code, entries, create=get_type_of_transaction(parent_doc, child_row) == "Inward" + ) + bundle = child_row.serial_and_batch_bundle if child_row.get("is_rejected"): bundle = child_row.rejected_serial_and_batch_bundle @@ -3605,8 +3609,8 @@ def get_batch_no_from_serial_no(serial_no: str): @frappe.whitelist(methods=["POST"]) -def is_serial_batch_no_exists( - item_code: str, type_of_transaction: str, serial_no: str | None = None, batch_no: str | None = None +def resolve_scanned_serial_batch_numbers( + item_code: str, serial_no: str | None = None, batch_no: str | None = None ): from erpnext.stock.serial_batch_identity import resolve_serial_batch_numbers @@ -3614,7 +3618,6 @@ def is_serial_batch_no_exists( item_code, serial_numbers=[serial_no] if serial_no else [], batch_numbers=[batch_no] if batch_no else [], - create=type_of_transaction == "Inward", ) diff --git a/erpnext/stock/doctype/serial_no/test_serial_no.py b/erpnext/stock/doctype/serial_no/test_serial_no.py index b1501a01503..e81eeeaa091 100644 --- a/erpnext/stock/doctype/serial_no/test_serial_no.py +++ b/erpnext/stock/doctype/serial_no/test_serial_no.py @@ -6,8 +6,6 @@ import frappe - -# Explicit names below model historical records referenced by legacy ledgers. from frappe import _dict from frappe.utils import add_days, nowdate, random_string 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 c4bed814be6..80cc7a9149c 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -58,8 +58,10 @@ "serial_batch_entries_html", "section_break_rdtg", "serial_no", + "serial_number", "column_break_prps", "batch_no", + "batch_number", "accounting", "expense_account", "accounting_dimensions_section", @@ -695,6 +697,24 @@ "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 3515666b690..9bd89adc7c4 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json @@ -29,14 +29,17 @@ "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", @@ -272,6 +275,33 @@ "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/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 78de7a9c08f..5af9a4b4fb5 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -487,7 +487,7 @@ class FIFOSlots: or [] ) - return serial_nos, batch_nos + return [name.upper() for name in serial_nos], batch_nos def _get_row_batch_nos(self, row: dict) -> list: if not row.batch_no: @@ -495,7 +495,7 @@ class FIFOSlots: return [ [ - row.batch_no, + row.batch_no.upper(), self._get_batchwise_valuation(row.batch_no), abs(row.actual_qty), abs(row.stock_value_difference), @@ -1148,7 +1148,7 @@ class FIFOSlots: bundle_wise_batch_nos = frappe._dict({}) for bundle_name, batch_no, use_batchwise_valuation, qty, stock_value_difference in query.run(): bundle_wise_batch_nos.setdefault(bundle_name, []).append( - [batch_no, use_batchwise_valuation, qty, stock_value_difference] + [batch_no.upper(), use_batchwise_valuation, qty, stock_value_difference] ) return bundle_wise_batch_nos diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 283d7181ea7..cdb4e4b5574 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -20,7 +20,7 @@ class TestStockAgeing(ERPNextTestSuite): def setUp(self) -> None: self.filters = frappe._dict(company="_Test Company", to_date="2021-12-10", ranges=["30", "60", "90"]) - def test_serial_ids_keep_their_case_in_fifo_slots(self): + def test_legacy_serial_references_match_regardless_of_case(self): rows = [ frappe._dict( name="Serialized Item", @@ -35,11 +35,11 @@ class TestStockAgeing(ERPNextTestSuite): serial_no=serials, ) for index, (qty, balance, date, serials) in enumerate( - [(2, 2, "2021-12-01", "id-aB\nid-Cd"), (-1, 1, "2021-12-02", "id-aB")] + [(2, 2, "2021-12-01", "id-aB\nid-Cd"), (-1, 1, "2021-12-02", "ID-Ab")] ) ] slots = FIFOSlots(self.filters, rows).generate() - self.assertEqual(slots["Serialized Item"]["fifo_queue"], [["id-Cd", "2021-12-01", 10.0]]) + self.assertEqual(slots["Serialized Item"]["fifo_queue"], [["ID-CD", "2021-12-01", 10.0]]) def test_normal_inward_outward_queue(self): "Reference: Case 1 in stock_ageing_fifo_logic.md (same wh)" @@ -583,8 +583,8 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual( queue, [ - [batch_no, 1, 10.0, "2021-12-01", 20.0], - [batch_no, 1, 2.0, "2021-12-02", 4.0], + [batch_no.upper(), 1, 10.0, "2021-12-01", 20.0], + [batch_no.upper(), 1, 2.0, "2021-12-02", 4.0], ], ) @@ -630,8 +630,8 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual( [slot[:4] for slot in queue], [ - [batch_no, 1, 10.0, "2021-12-01"], - [batch_no, 1, 2.0, "2021-12-01"], + [batch_no.upper(), 1, 10.0, "2021-12-01"], + [batch_no.upper(), 1, 2.0, "2021-12-01"], ], ) self.assertAlmostEqual(queue[0][4], 1166.67, places=2) @@ -678,8 +678,8 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual( queue, [ - [batch_no, 1, 10.0, "2021-12-01", 50.0], - [batch_no, 1, 10.0, "2021-12-01", 50.0], + [batch_no.upper(), 1, 10.0, "2021-12-01", 50.0], + [batch_no.upper(), 1, 10.0, "2021-12-01", 50.0], ], ) @@ -767,8 +767,8 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual( queue, [ - [batch_no, 1, 6.0, "2021-12-01", 30.0], - [batch_no, 1, 10.0, "2021-12-01", 50.0], + [batch_no.upper(), 1, 6.0, "2021-12-01", 30.0], + [batch_no.upper(), 1, 10.0, "2021-12-01", 50.0], ], ) @@ -1588,13 +1588,13 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual( item_result["fifo_queue"], [ - [batchwise_above_90, 1, 40.0, "2021-08-01", 400.0], - [batchwise_61_90, 1, 35.0, "2021-09-20", 350.0], - [non_batchwise_61_90, 0, 40.0, "2021-09-25", 400.0], - [batchwise_31_60, 1, 22.0, "2021-10-20", 220.0], - [non_batchwise_31_60, 0, 40, "2021-10-25", 400], - [batchwise_0_30, 1, 14.0, "2021-11-20", 140.0], - [non_batchwise_0_30, 0, 30, "2021-11-25", 300], + [batchwise_above_90.upper(), 1, 40.0, "2021-08-01", 400.0], + [batchwise_61_90.upper(), 1, 35.0, "2021-09-20", 350.0], + [non_batchwise_61_90.upper(), 0, 40.0, "2021-09-25", 400.0], + [batchwise_31_60.upper(), 1, 22.0, "2021-10-20", 220.0], + [non_batchwise_31_60.upper(), 0, 40, "2021-10-25", 400], + [batchwise_0_30.upper(), 1, 14.0, "2021-11-20", 140.0], + [non_batchwise_0_30.upper(), 0, 30, "2021-11-25", 300], ], ) @@ -1673,8 +1673,8 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual( item_result["fifo_queue"], [ - [source_batch, 1, 5.0, "2021-09-01", 50.0], - [target_batch, 1, 10.0, "2021-09-01", 100.0], + [source_batch.upper(), 1, 5.0, "2021-09-01", 50.0], + [target_batch.upper(), 1, 10.0, "2021-09-01", 100.0], ], ) self.assertEqual( @@ -1718,7 +1718,7 @@ class TestStockAgeing(ERPNextTestSuite): slots = fifo_slots.generate() item_result = slots[item_code] - self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -10, "2021-12-01", -100]]) + self.assertEqual(item_result["fifo_queue"], [[batch_no.upper(), 1, -10, "2021-12-01", -100]]) self.assertEqual( fifo_slots.transferred_item_details[("001", item_code, "WH 1")], [[10, "2021-12-01", 100]] ) @@ -1745,7 +1745,7 @@ class TestStockAgeing(ERPNextTestSuite): slots = fifo_slots.generate() item_result = slots[item_code] - self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-12-01", -40.0]]) + self.assertEqual(item_result["fifo_queue"], [[batch_no.upper(), 1, -4.0, "2021-12-01", -40.0]]) self.assertEqual( fifo_slots.transferred_item_details[("001", item_code, "WH 1")], [[4.0, "2021-12-01", 40.0]], @@ -1822,8 +1822,8 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual( item_result["fifo_queue"], [ - [buffer_batch, 1, 5, "2021-11-30", 50], - [negative_batch, 1, -4.0, "2021-12-01", -40.0], + [buffer_batch.upper(), 1, 5, "2021-11-30", 50], + [negative_batch.upper(), 1, -4.0, "2021-12-01", -40.0], ], ) self.assertEqual( @@ -1883,7 +1883,7 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) self.assertEqual(item_result["total_qty"], -4.0) - self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]]) + self.assertEqual(item_result["fifo_queue"], [[batch_no.upper(), 1, -4.0, "2021-11-10", -40.0]]) def test_untagged_receipt_with_negative_batch_head(self): """An incoming SLE without batch details must not treat a negative @@ -1984,7 +1984,7 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) self.assertEqual(item_result["total_qty"], 5.0) self.assertEqual( - item_result["fifo_queue"], [[batch_no, 1, 5.0, getdate(add_days(base_date, -2)), 50.0]] + item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]] ) def test_legacy_batch_no_sle_with_streaming_cursor(self): diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 51418631455..0a366b61934 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -1390,7 +1390,7 @@ class SerialBatchCreation: self.batches = frappe._dict({self.batch_no: abs(self.actual_qty)}) def make_serial_no_if_not_exists(self): - # Transaction fields contain IDs. Physical input is resolved by the input API. + # Transaction fields contain IDs. Physical input is resolved during save. existing = set( frappe.get_all( "Serial No", @@ -1402,21 +1402,6 @@ class SerialBatchCreation: if name not in existing: frappe.throw(_("Serial No {0} does not exist for Item {1}").format(name, self.item_code)) - def make_serial_nos(self, serial_nos): - from erpnext.stock.serial_batch_identity import SerialBatchIdentity - - return SerialBatchIdentity("Serial No").resolve( - self.item_code, - serial_nos, - create=True, - defaults={ - "warehouse": self.warehouse, - "company": self.company, - "status": "Active", - "batch_no": next(iter(self.batches), None) if self.get("batches") else None, - }, - ) - def set_serial_batch_entries(self, doc): incoming_rate = self.get("incoming_rate") @@ -1532,19 +1517,28 @@ class SerialBatchCreation: current_value += 1 numbers.append(parse_naming_series(self.serial_no_series, number_generator=get_series)) - ids = SerialBatchIdentity("Serial No").create_many( - self.item_code, - numbers, - defaults={ - "warehouse": self.warehouse, - "company": self.company, - "status": "Active", - "reference_doctype": self.get("voucher_type"), - "reference_name": self.get("voucher_no"), - "posting_date": self.get("posting_date") or getdate(self.posting_datetime), - "batch_no": self.get("batch_no"), - }, - ) + try: + ids = SerialBatchIdentity("Serial No").create_many( + self.item_code, + numbers, + defaults={ + "warehouse": self.warehouse, + "company": self.company, + "status": "Active", + "reference_doctype": self.get("voucher_type"), + "reference_name": self.get("voucher_no"), + "posting_date": self.get("posting_date") or getdate(self.posting_datetime), + "batch_no": self.get("batch_no"), + }, + ) + except frappe.DuplicateEntryError: + frappe.throw( + _( + "A generated serial number already exists. Change the Serial No Series for Item {0} or correct its current counter." + ).format(self.item_code), + frappe.DuplicateEntryError, + ) + series.update_counter(current_value) return [ids[number] for number in numbers] diff --git a/erpnext/stock/serial_batch_display.py b/erpnext/stock/serial_batch_display.py index e1c3fd4d7f6..67df7733268 100644 --- a/erpnext/stock/serial_batch_display.py +++ b/erpnext/stock/serial_batch_display.py @@ -1,9 +1,12 @@ """Display physical numbers while retaining document IDs for stock references.""" +from copy import copy from functools import wraps import frappe from frappe import _ +from frappe.model.base_document import BaseDocument +from frappe.utils import escape_html from erpnext.stock.serial_batch_identity import SerialBatchIdentity @@ -59,10 +62,6 @@ def report_number_columns(columns, rows): def before_print(doc, method=None, print_settings=None, **kwargs): - # Link fields use Frappe's title formatter. Legacy serial lists are plain text. - if doc.flags.serial_numbers_formatted: - return - doc.flags.serial_numbers_formatted = True rows = [doc, *doc.get_all_children()] fields = ("serial_no", "rejected_serial_no", "current_serial_no") serial_rows = [ @@ -77,5 +76,22 @@ def before_print(doc, method=None, print_settings=None, **kwargs): if meta and meta.fieldtype in ("Small Text", "Text", "Long Text") and row.get(field): values.append((row, field, row.get(field).split("\n"))) labels = SerialBatchIdentity("Serial No").labels([name for _, _, names in values for name in names]) - for row, field, names in values: - row.set(field, "\n".join(labels.get(name, name) for name in names)) + for row, _field, _names in values: + row.__dict__["__serial_number_labels"] = labels + + +class SerialNumberDisplay: + def get_formatted(self, fieldname, *args, **kwargs): + field = self.meta.get_field(fieldname) + if ( + fieldname not in ("serial_no", "rejected_serial_no", "current_serial_no") + or not field + or field.fieldtype not in ("Small Text", "Text", "Long Text") + or not self.get(fieldname) + ): + return super().get_formatted(fieldname, *args, **kwargs) + names = self.get(fieldname).split("\n") + labels = self.get("__serial_number_labels") or SerialBatchIdentity("Serial No").labels(names) + print_row = copy(self) + print_row.set(fieldname, "\n".join(escape_html(labels.get(name, name)) for name in names)) + return BaseDocument.get_formatted(print_row, fieldname, *args, **kwargs) diff --git a/erpnext/stock/serial_batch_fields.py b/erpnext/stock/serial_batch_fields.py new file mode 100644 index 00000000000..50927089c55 --- /dev/null +++ b/erpnext/stock/serial_batch_fields.py @@ -0,0 +1,22 @@ +SERIAL_TEXT_DOCTYPES = ( + "Asset Capitalization Stock Item", + "Asset Repair Consumed Item", + "Delivery Note Item", + "Installation Note Item", + "Maintenance Schedule Detail", + "Maintenance Schedule Item", + "POS Invoice Item", + "Packed Item", + "Pick List Item", + "Purchase Invoice Item", + "Purchase Receipt Item", + "Purchase Receipt Item Supplied", + "Sales Invoice Item", + "Stock Entry Detail", + "Stock Ledger Entry", + "Stock Reconciliation Item", + "Subcontracting Receipt Item", + "Subcontracting Receipt Supplied Item", +) + +NUMBER_INPUT_DOCTYPES = tuple(doctype for doctype in SERIAL_TEXT_DOCTYPES if doctype != "Stock Ledger Entry") diff --git a/erpnext/stock/serial_batch_identity.py b/erpnext/stock/serial_batch_identity.py index 7c0cb93914b..9e4a995e574 100644 --- a/erpnext/stock/serial_batch_identity.py +++ b/erpnext/stock/serial_batch_identity.py @@ -161,8 +161,19 @@ class SerialBatchIdentity: table[self.number_field].isnull() | (table[self.number_field] == "") ).run() + def has_constraint(self): + index = ( + ("serial_no_number_item_ci" if self.doctype == "Serial No" else "batch_number_item_ci") + if frappe.db.db_type == "postgres" + else f"unique_{self.item_field}_{self.number_field}" + ) + return bool(frappe.db.has_index(f"tab{self.doctype}", index)) + def sync_constraint(self): - self.validate_existing_numbers() + if self.has_constraint(): + return + if self.doctype not in (frappe.flags.serial_batch_preflight or ()): + self.validate_existing_numbers() self.backfill_numbers() if frappe.db.db_type == "postgres": # The leading number expression also indexes scans without an item filter. @@ -266,22 +277,22 @@ def get_serial_batch_labels(doctype: str, names: list | str): @frappe.whitelist(methods=["POST"]) def resolve_transaction_serial_numbers(parent: dict | str, row: dict | str, numbers: list | str): - from erpnext.stock.doctype.serial_and_batch_bundle.inline_editor import SUPPORTED_VOUCHER_TYPES - from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import get_type_of_transaction - - parent, row = frappe._dict(frappe.parse_json(parent)), frappe._dict(frappe.parse_json(row)) + parent, row = frappe.parse_json(parent), frappe.parse_json(row) + if not isinstance(parent, dict) or not isinstance(parent.get("doctype"), str): + frappe.throw(_("Transaction DocType is required")) + if not frappe.db.exists("DocType", parent["doctype"]): + frappe.throw(_("Invalid transaction DocType")) + if not isinstance(row, dict) or not (row.get("item_code") or row.get("rm_item_code")): + frappe.throw(_("Item is required")) frappe.has_permission( - parent.doctype, "write", doc=parent.name if not parent.__islocal else None, throw=True - ) - frappe.has_permission("Item", "read", doc=row.item_code or row.rm_item_code, throw=True) - create = parent.doctype in SUPPORTED_VOUCHER_TYPES and get_type_of_transaction(parent, row) == "Inward" - frappe.has_permission("Serial No", "create" if create else "read", throw=True) - return SerialBatchIdentity("Serial No").resolve( - row.item_code or row.rm_item_code, - frappe.parse_json(numbers), - create=create, - defaults={"company": parent.company}, + parent["doctype"], + "write", + doc=parent.get("name") if not parent.get("__islocal") else None, + throw=True, ) + return resolve_serial_batch_numbers( + row.get("item_code") or row.get("rm_item_code"), serial_numbers=numbers + )["serial_nos"] def add_number_labels(entries): @@ -303,11 +314,39 @@ def resolve_number_entries(item_code, entries, *, create=False): ("serial_no", "Serial No", "serial_number"), ): rows = [row for row in entries if row.get(number_field) and not row.get(field)] - ids = SerialBatchIdentity(doctype).resolve( - item_code, [row[number_field] for row in rows], create=create + identity = SerialBatchIdentity(doctype) + missing = create and any(not identity.exists(row[number_field], item_code) for row in rows) + ids = ( + resolve_serial_batch_numbers( + item_code, + **{ + "serial_numbers" if doctype == "Serial No" else "batch_numbers": [ + row[number_field] for row in rows + ] + }, + create=missing, + )["serial_nos" if doctype == "Serial No" else "batch_nos"] + if rows + else [] ) for row, name in zip(rows, ids, strict=True): row[field] = name + serials = [row["serial_no"] for row in entries if row.get("serial_no") and not row.get("batch_no")] + batches = ( + dict( + frappe.get_all( + "Serial No", + filters={"name": ("in", serials), "item_code": item_code}, + fields=["name", "batch_no"], + as_list=True, + ) + ) + if serials + else {} + ) + for row in entries: + if not row.get("batch_no") and row.get("serial_no") in batches and batches[row["serial_no"]]: + row["batch_no"] = batches[row["serial_no"]] return entries diff --git a/erpnext/stock/serial_batch_input.py b/erpnext/stock/serial_batch_input.py new file mode 100644 index 00000000000..73283828de2 --- /dev/null +++ b/erpnext/stock/serial_batch_input.py @@ -0,0 +1,83 @@ +import frappe +from frappe import _ + +from erpnext.stock.serial_batch_fields import NUMBER_INPUT_DOCTYPES +from erpnext.stock.serial_batch_identity import SerialBatchIdentity, resolve_serial_batch_numbers + + +def resolve_transaction_numbers(doc, method=None): + if doc.docstatus == 2: + return + for row in [doc, *doc.get_all_children()]: + if row.doctype in NUMBER_INPUT_DOCTYPES: + TransactionNumberInput(doc, row).resolve() + + +class TransactionNumberInput: + def __init__(self, doc, row): + self.doc = doc + self.row = row + 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: + continue + if not isinstance(value, str): + frappe.throw(_("Physical numbers must be text")) + numbers = ( + [value.strip()] + if field == "batch_no" and value.strip() + else [number.strip() for number in value.replace(",", "\n").splitlines() if number.strip()] + ) + 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) + if names and self.row.meta.has_field("use_serial_batch_fields"): + self.row.use_serial_batch_fields = 1 + + def resolve_numbers(self, field, numbers): + doctype = "Batch" if field == "batch_no" else "Serial No" + identity = SerialBatchIdentity(doctype) + if not self.item_code: + frappe.throw(_("Item is required")) + frappe.has_permission("Item", "read", doc=self.item_code, throw=True) + existing = { + record[identity.number_field] + for record in identity.get_query(numbers, self.item_code).run(as_dict=True) + } + missing = any( + number not in existing and not identity.exists(number, self.item_code) for number in numbers + ) + if missing and self.can_create(field): + frappe.has_permission(doctype, "create", throw=True) + identity.resolve( + self.item_code, numbers, create=True, defaults={"company": self.doc.get("company")} + ) + key = "batch_numbers" if field == "batch_no" else "serial_numbers" + names = resolve_serial_batch_numbers(self.item_code, **{key: numbers})[ + "batch_nos" if field == "batch_no" else "serial_nos" + ] + if doctype == "Serial No" and len(set(names)) != len(names): + frappe.throw(_("A serial number cannot appear twice in the same row")) + return names + + def can_create(self, field): + from erpnext.stock.doctype.serial_and_batch_bundle.inline_editor import SUPPORTED_VOUCHER_TYPES + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_type_of_transaction, + ) + + row = frappe._dict(self.row.as_dict()) + row.pop("type_of_transaction", None) + return ( + field != "current_serial_no" + and self.doc.doctype in SUPPORTED_VOUCHER_TYPES + and get_type_of_transaction(self.doc, row) == "Inward" + ) diff --git a/erpnext/stock/tests/test_serial_batch_identity.py b/erpnext/stock/tests/test_serial_batch_identity.py index 07260de4c1e..33877399427 100644 --- a/erpnext/stock/tests/test_serial_batch_identity.py +++ b/erpnext/stock/tests/test_serial_batch_identity.py @@ -223,7 +223,8 @@ class TestSerialBatchIdentity(ERPNextTestSuite): print_doc.items[0].serial_no = name before_print(print_doc) before_print(print_doc) - self.assertEqual(print_doc.items[0].serial_no, "PRINT-123") + self.assertEqual(print_doc.items[0].serial_no, name) + self.assertIn("PRINT-123", print_doc.items[0].get_formatted("serial_no")) entry = frappe.get_doc("Serial and Batch Bundle", pr.items[0].serial_and_batch_bundle).entries[0] self.assertEqual(entry.serial_no, name) print_format = frappe.get_doc( @@ -233,10 +234,11 @@ class TestSerialBatchIdentity(ERPNextTestSuite): "doc_type": "Purchase Receipt", "print_format_type": "Jinja", "custom_format": 1, - "html": "{{ get_serial_or_batch_nos(doc.items[0].serial_and_batch_bundle) }}", + "html": "{{ doc.items[0].get_formatted('serial_no') }}", } ).insert() - printed = frappe.get_print("Purchase Receipt", pr.name, print_format=print_format.name) + printed = frappe.get_print("Purchase Receipt", pr.name, print_format=print_format.name, doc=print_doc) + self.assertEqual(print_doc.items[0].serial_no, name) self.assertIn("PRINT-123", printed) self.assertNotIn(name, printed) pr.cancel() diff --git a/erpnext/stock/tests/test_serial_batch_identity_access.py b/erpnext/stock/tests/test_serial_batch_identity_access.py index 2f5794ad627..1484cb03dc2 100644 --- a/erpnext/stock/tests/test_serial_batch_identity_access.py +++ b/erpnext/stock/tests/test_serial_batch_identity_access.py @@ -1,7 +1,7 @@ import frappe from erpnext.stock.doctype.item.test_item import make_item -from erpnext.stock.serial_batch_identity import resolve_transaction_serial_numbers +from erpnext.stock.serial_batch_identity import SerialBatchIdentity, resolve_transaction_serial_numbers from erpnext.tests.utils import ERPNextTestSuite @@ -17,7 +17,7 @@ class TestSerialBatchIdentityAccess(ERPNextTestSuite): } ).insert() - def test_inward_resolution_requires_serial_create_permission(self): + def test_read_only_resolution_never_creates_missing_serials(self): item = make_item(properties={"has_serial_no": 1}) user = self.make_stock_user() parent = {"doctype": "Purchase Receipt", "__islocal": 1, "company": "_Test Company"} @@ -26,7 +26,7 @@ class TestSerialBatchIdentityAccess(ERPNextTestSuite): self.assertTrue(frappe.has_permission("Purchase Receipt", "write")) self.assertTrue(frappe.has_permission("Serial No", "read")) self.assertFalse(frappe.has_permission("Serial No", "create")) - with self.assertRaises(frappe.PermissionError): + with self.assertRaises(frappe.ValidationError): resolve_transaction_serial_numbers(parent, row, ["UNAUTHORIZED-SERIAL"]) self.assertFalse(frappe.db.exists("Serial No", {"item_code": item.name})) @@ -34,7 +34,7 @@ class TestSerialBatchIdentityAccess(ERPNextTestSuite): item = make_item(properties={"has_serial_no": 1}) parent = {"doctype": "Purchase Receipt", "__islocal": 1, "company": "_Test Company"} row = {"item_code": item.name, "qty": 1} - names = resolve_transaction_serial_numbers(parent, row, ["EXISTING-SERIAL"]) + names = SerialBatchIdentity("Serial No").resolve(item.name, ["EXISTING-SERIAL"], create=True) parent["is_return"] = 1 row["qty"] = -1 user = self.make_stock_user() @@ -42,13 +42,53 @@ class TestSerialBatchIdentityAccess(ERPNextTestSuite): self.assertFalse(frappe.has_permission("Serial No", "create")) self.assertEqual(resolve_transaction_serial_numbers(parent, row, ["EXISTING-SERIAL"]), names) - def test_authorized_inward_resolution_creates_serials(self): + def test_even_authorized_resolution_does_not_create_serials(self): item = make_item(properties={"has_serial_no": 1}) - parent = {"doctype": "Purchase Receipt", "__islocal": 1, "company": "_Test Company"} - row = {"item_code": item.name, "qty": 1} - names = resolve_transaction_serial_numbers(parent, row, ["AUTHORIZED-SERIAL"]) - serial = frappe.get_doc("Serial No", names[0]) - self.assertEqual(serial.item_code, item.name) - self.assertEqual(serial.serial_no, "AUTHORIZED-SERIAL") - self.assertEqual(serial.company, parent["company"]) - self.assertEqual(resolve_transaction_serial_numbers(parent, row, ["AUTHORIZED-SERIAL"]), names) + with self.assertRaises(frappe.ValidationError): + resolve_transaction_serial_numbers( + {"doctype": "Purchase Receipt", "__islocal": 1, "company": "_Test Company"}, + {"item_code": item.name}, + ["MISSING-SERIAL"], + ) + self.assertFalse(frappe.db.exists("Serial No", {"item_code": item.name})) + + def test_missing_transaction_doctype_is_a_validation_error(self): + for parent in ({}, {"doctype": ""}, {"doctype": "No Such Transaction"}, "[]"): + with self.assertRaises(frappe.ValidationError): + resolve_transaction_serial_numbers(parent, {"item_code": "Item"}, ["SERIAL"]) + + def test_draft_save_requires_permission_to_create_missing_serials(self): + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + 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" + with self.set_user(user.name): + with self.assertRaises(frappe.PermissionError): + receipt.insert() + self.assertFalse(frappe.db.exists("Serial No", {"item_code": item.name})) + + def test_bundle_number_input_checks_creation_permissions(self): + from erpnext.stock.serial_batch_identity import resolve_number_entries + + item = make_item(properties={"has_serial_no": 1}) + serial = SerialBatchIdentity("Serial No").resolve(item.name, ["Existing"], create=True)[0] + user = self.make_stock_user() + with self.set_user(user.name): + entries = [{"serial_number": "Existing"}] + resolve_number_entries(item.name, entries, create=True) + self.assertEqual(entries[0]["serial_no"], serial) + with self.assertRaises(frappe.PermissionError): + resolve_number_entries(item.name, [{"serial_number": "Unauthorized"}], create=True) + self.assertFalse(SerialBatchIdentity("Serial No").exists("Unauthorized", item.name)) + + def test_bundle_scanning_does_not_create_records(self): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + resolve_scanned_serial_batch_numbers, + ) + + item = make_item(properties={"has_serial_no": 1}) + with self.assertRaises(frappe.ValidationError): + resolve_scanned_serial_batch_numbers(item.name, serial_no="Missing") + self.assertFalse(frappe.db.exists("Serial No", {"item_code": item.name})) diff --git a/erpnext/stock/tests/test_serial_batch_identity_matching.py b/erpnext/stock/tests/test_serial_batch_identity_matching.py index b155308af55..b80c27daac3 100644 --- a/erpnext/stock/tests/test_serial_batch_identity_matching.py +++ b/erpnext/stock/tests/test_serial_batch_identity_matching.py @@ -104,7 +104,7 @@ class TestSerialBatchIdentityMatching(ERPNextTestSuite): def test_migration_reports_case_conflicts_before_changing_records(self): if frappe.db.db_type != "postgres": self.skipTest("Legacy case-only duplicates are possible on PostgreSQL") - from erpnext.patches.separate_serial_batch_identity import execute + from erpnext.patches.v17_0.separate_serial_batch_identity import execute for doctype, index in (("Serial No", "serial_no_number_item_ci"), ("Batch", "batch_number_item_ci")): identity, item, name = self.make_number(doctype, "Legacy-Lot") diff --git a/erpnext/stock/tests/test_serial_batch_input.py b/erpnext/stock/tests/test_serial_batch_input.py new file mode 100644 index 00000000000..5a8bf5ee7a6 --- /dev/null +++ b/erpnext/stock/tests/test_serial_batch_input.py @@ -0,0 +1,177 @@ +import csv +from tempfile import NamedTemporaryFile +from unittest.mock import patch + +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.serial_batch_identity import SerialBatchIdentity +from erpnext.tests.utils import ERPNextTestSuite + + +class TestSerialBatchInput(ERPNextTestSuite): + def make_receipt(self, **properties): + item = make_item(properties={"has_serial_no": 1, "has_batch_no": 1, **properties}) + return make_purchase_receipt(item_code=item.name, qty=1, rate=100, do_not_save=True) + + def test_physical_input_is_created_on_save_and_used_on_submit(self): + ids = [] + for _ in range(2): + receipt = self.make_receipt() + row = receipt.items[0] + row.serial_number, row.batch_number = "Physical-Serial", "Physical-Batch" + 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.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") + ids.append(row.serial_no) + receipt.submit() + entry = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle).entries[0] + self.assertEqual(entry.serial_no, ids[-1]) + self.assertEqual(frappe.get_doc("Serial No", ids[-1]).serial_no, "Physical-Serial") + receipt.cancel() + self.assertNotEqual(*ids) + + 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" + frappe.db.savepoint("failed_physical_input") + try: + with patch.object(type(receipt), "validate", side_effect=frappe.ValidationError): + with self.assertRaises(frappe.ValidationError): + receipt.insert() + finally: + frappe.db.rollback(save_point="failed_physical_input") + self.assertFalse(frappe.db.exists("Serial No", {"item_code": row.item_code})) + self.assertFalse(frappe.db.exists("Batch", {"item": row.item_code})) + + def test_auto_numbering_still_runs_on_submit(self): + receipt = self.make_receipt( + serial_no_series="AUTO-" + frappe.generate_hash() + "-.#####", create_new_batch=1 + ) + receipt.insert() + item = receipt.items[0].item_code + self.assertFalse(frappe.db.exists("Serial No", {"item_code": item})) + receipt.submit() + receipt.reload() + bundle = frappe.get_doc("Serial and Batch Bundle", receipt.items[0].serial_and_batch_bundle) + self.assertEqual(len(bundle.entries), 1) + self.assertEqual(frappe.get_doc("Serial No", bundle.entries[0].serial_no).status, "Active") + receipt.cancel() + + def test_series_collision_explains_how_to_fix_it(self): + prefix = "COLLISION-" + frappe.generate_hash() + "-" + receipt = self.make_receipt(has_batch_no=0, serial_no_series=prefix + ".#####") + item = receipt.items[0].item_code + SerialBatchIdentity("Serial No").resolve(item, [prefix + "00001"], create=True) + receipt.insert() + frappe.db.savepoint("series_collision") + try: + with self.assertRaises(frappe.DuplicateEntryError) as error: + receipt.submit() + self.assertIn("Serial No Series", str(error.exception)) + self.assertIn(item, str(error.exception)) + finally: + frappe.db.rollback(save_point="series_collision") + + def test_existing_constraints_skip_data_scans(self): + for doctype in ("Serial No", "Batch"): + identity = SerialBatchIdentity(doctype) + self.assertTrue(identity.has_constraint()) + with patch.object(identity, "validate_existing_numbers") as validate: + with patch.object(identity, "backfill_numbers") as backfill: + identity.sync_constraint() + validate.assert_not_called() + backfill.assert_not_called() + + 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" + with self.assertRaises(frappe.ValidationError): + receipt.insert() + + def test_internal_id_and_physical_input_cannot_conflict(self): + receipt = self.make_receipt(has_batch_no=0) + row = receipt.items[0] + row.serial_no = SerialBatchIdentity("Serial No").resolve(row.item_code, ["One"], create=True)[0] + row.serial_number = "Another" + with self.assertRaises(frappe.ValidationError): + receipt.insert() + + 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, + ) + + receipt = self.make_receipt() + receipt.insert() + row = receipt.items[0] + bundle = add_serial_batch_ledgers( + entries=[{"serial_number": "Bundle-Serial", "batch_number": "Bundle-Batch", "qty": 1}], + child_row=row.as_dict(), + doc=receipt.as_dict(), + ) + entry = bundle.entries[0] + self.assertNotEqual(entry.serial_no, "Bundle-Serial") + self.assertNotEqual(entry.batch_no, "Bundle-Batch") + self.assertEqual(frappe.get_doc("Serial No", entry.serial_no).serial_no, "Bundle-Serial") + self.assertEqual(frappe.get_doc("Batch", entry.batch_no).batch_id, "Bundle-Batch") + + def test_bundle_entry_preserves_an_explicit_batch(self): + from erpnext.stock.serial_batch_identity import resolve_number_entries + + receipt = self.make_receipt() + item = receipt.items[0].item_code + batch, other_batch = SerialBatchIdentity("Batch").resolve(item, ["One", "Two"], create=True) + serial = SerialBatchIdentity("Serial No").resolve( + item, ["Bundled"], create=True, defaults={"batch_no": batch} + )[0] + entries = [{"serial_no": serial}, {"serial_no": serial, "batch_no": other_batch}] + resolve_number_entries(item, entries) + 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 + + receipt = self.make_receipt() + with NamedTemporaryFile(mode="w+", suffix=".csv") as file: + writer = csv.writer(file) + writer.writerow( + [ + "supplier", + "company", + "items.item_code", + "items.qty", + "items.rate", + "items.warehouse", + "items.serial_number", + "items.batch_number", + ] + ) + writer.writerow( + [ + receipt.supplier, + receipt.company, + receipt.items[0].item_code, + 1, + 100, + receipt.items[0].warehouse, + "Imported-Serial", + "Imported-Batch", + ] + ) + file.flush() + payloads = ImportFile( + "Purchase Receipt", file.name, import_type="Insert New Records", console=True + ).get_payloads_for_import() + self.assertEqual(len(payloads), 1) + imported = frappe.new_doc("Purchase Receipt").update(payloads[0].doc).insert() + 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") 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 61dce1d4e7b..270c805d835 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json @@ -64,9 +64,12 @@ "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", @@ -661,6 +664,33 @@ "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 ec10fd07146..4e601079475 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,8 +34,10 @@ "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", @@ -271,6 +273,24 @@ "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,