fix(stock): reuse transaction serial and batch fields

This commit is contained in:
Mihir Kandoi
2026-09-09 07:11:49 +05:30
parent fa244a3615
commit 92b6d708d8
29 changed files with 359 additions and 403 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,

View File

@@ -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]);

View File

@@ -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];
},
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 (

View File

@@ -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())

View File

@@ -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"

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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:

View File

@@ -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,

View File

@@ -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,

View File

@@ -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