fix(stock): apply serial and batch identity review fixes

This commit is contained in:
Mihir Kandoi
2026-09-08 22:20:12 +05:30
parent f80cac927d
commit d81fe03776
41 changed files with 1044 additions and 158 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": []
}
}

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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