mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 23:39:28 +00:00
fix(stock): apply serial and batch identity review fixes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
0
erpnext/patches/v17_0/__init__.py
Normal file
0
erpnext/patches/v17_0/__init__.py
Normal file
20
erpnext/patches/v17_0/separate_serial_batch_identity.py
Normal file
20
erpnext/patches/v17_0/separate_serial_batch_identity.py
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
22
erpnext/stock/serial_batch_fields.py
Normal file
22
erpnext/stock/serial_batch_fields.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
83
erpnext/stock/serial_batch_input.py
Normal file
83
erpnext/stock/serial_batch_input.py
Normal 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"
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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")
|
||||
|
||||
177
erpnext/stock/tests/test_serial_batch_input.py
Normal file
177
erpnext/stock/tests/test_serial_batch_input.py
Normal 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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user