mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 23:39:28 +00:00
fix(stock): reuse transaction serial and batch fields
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
96
erpnext/stock/serial_batch_import.py
Normal file
96
erpnext/stock/serial_batch_import.py
Normal 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())
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user