feat(stock)!: separate serial and batch numbers from document IDs

This commit is contained in:
Mihir Kandoi
2026-09-08 12:00:05 +05:30
parent ed368a3e2a
commit ca880f6be7
62 changed files with 1964 additions and 652 deletions

View File

@@ -0,0 +1,25 @@
# Serial and batch numbers are unique per item
Serial No and Batch now have generated document IDs. Their physical numbers remain in `Serial No.serial_no` and `Batch.batch_id`.
Different items can use the same physical number. Two records for the same item cannot share a number.
Existing document IDs remain unchanged. Migration fills missing physical numbers from those IDs and replaces the global unique indexes.
Historical transactions, bundles, and stock valuations retain their references. Item merges fail when they would introduce duplicate numbers.
When a scanned number matches several items, select the item from the filtered Item field.
![Select an item when a scanned number matches several items](serial_batch_item_picker.png)
## Integration changes for the next major release
- Treat `name` as the document ID. Do not construct it from the physical number.
- Use returned document IDs in transaction fields and Serial and Batch Entry links, including legacy serial-number text lists.
- Resolve physical numbers with `erpnext.stock.serial_batch_identity.resolve_serial_batch_numbers`. Supply `item_code` and lists named `serial_numbers` or `batch_numbers`.
- The resolver returns `serial_nos` and `batch_nos`, containing document IDs in input order. Set `create` only for authorized creation.
- For the bundle editor, use `serial_number` and `batch_number` for physical input. Use `serial_no` and `batch_no` for existing links.
- CSV uploads continue accepting physical numbers. CSV downloads and standard prints show physical numbers.
- A barcode scan can match several records. Interactive callers pass `allow_multiple=true` and select a returned candidate before updating a transaction.
- Reports retain ID columns as hidden fields and provide separate visible physical-number columns. Report filters still accept document IDs.
Custom integrations and print formats must use physical-number fields for labels and document IDs for links.
After new duplicate numbers exist, restoring an older release requires restoring the pre-upgrade database backup.

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -596,13 +596,15 @@ def get_batch_no(doctype: str, txt: str, searchfield: str, start: int, page_len:
if filters.get("is_inward"):
filtered_batches.extend(get_empty_batches(filters, start, page_len, filtered_batches, txt))
return filtered_batches
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
labels = SerialBatchIdentity("Batch").labels([row[0] for row in filtered_batches])
return [(row[0], labels.get(row[0], row[0]), *row[1:]) for row in filtered_batches]
def get_empty_batches(filters, start, page_len, filtered_batches=None, txt=None):
query_filter = {"item": filters.get("item_code"), "disabled": 0}
if txt:
query_filter["name"] = ("like", f"%{txt}%")
or_filters = {"batch_id": ("like", f"%{txt}%"), "name": txt} if txt else None
exclude_batches = [batch[0] for batch in filtered_batches] if filtered_batches else []
if exclude_batches:
@@ -612,6 +614,7 @@ def get_empty_batches(filters, start, page_len, filtered_batches=None, txt=None)
"Batch",
fields=["name", "batch_qty"],
filters=query_filter,
or_filters=or_filters,
limit_start=start,
limit_page_length=page_len,
as_list=1,
@@ -687,7 +690,7 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p
query = query.select(batch_table[field])
if txt:
txt_condition = batch_table.name.like(f"%{txt}%")
txt_condition = batch_table.batch_id.like(f"%{txt}%")
for field in [*searchfields, "name"]:
txt_condition |= batch_table[field].like(f"%{txt}%")
@@ -753,7 +756,7 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0
bundle_query = bundle_query.select(batch_table[field])
if txt:
txt_condition = batch_table.name.like(f"%{txt}%")
txt_condition = batch_table.batch_id.like(f"%{txt}%")
for field in [*searchfields, "name"]:
txt_condition |= batch_table[field].like(f"%{txt}%")
@@ -1018,11 +1021,11 @@ def get_batch_numbers(doctype: str, txt: str, searchfield: str, start: int, page
batch = frappe.qb.DocType("Batch")
query = (
frappe.qb.from_(batch)
.select(batch.batch_id)
.select(batch.name, batch.batch_id, batch.item)
.where(
(batch.disabled == 0)
& (batch.expiry_date.isnull() | (batch.expiry_date >= today()))
& batch.name.like(f"%{txt}%")
& batch.batch_id.like(f"%{txt}%")
)
)

View File

@@ -384,6 +384,7 @@ pre_submit_validation_doctypes = [
doc_events = {
"*": {
"before_print": "erpnext.stock.serial_batch_display.before_print",
"validate": [
"erpnext.support.doctype.service_level_agreement.service_level_agreement.apply",
"erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.check_for_running_deletion_job",

View File

@@ -830,58 +830,30 @@ class WorkOrder(Document):
serial_nos = []
if item_details.serial_no_series:
serial_nos = get_available_serial_nos(item_details.serial_no_series, self.qty)
serial_nos = get_available_serial_nos(
item_details.serial_no_series, self.qty, self.production_item
)
if not serial_nos:
return
fields = [
"name",
"serial_no",
"creation",
"modified",
"owner",
"modified_by",
"company",
"item_code",
"item_name",
"description",
"status",
"work_order",
"batch_no",
]
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
serial_nos_details = []
index = 0
for serial_no in serial_nos:
index += 1
batch_no = None
if batches and self.batch_size:
batch_no = batches[0]
groups = {}
for index, number in enumerate(serial_nos, 1):
batch_no = batches[0] if batches and self.batch_size else None
groups.setdefault(batch_no, []).append(number)
if batch_no and index % self.batch_size == 0:
batches.pop(0)
if index % self.batch_size == 0:
batches.remove(batch_no)
serial_nos_details.append(
(
serial_no,
serial_no,
now(),
now(),
frappe.session.user,
frappe.session.user,
self.company,
self.production_item,
item_details.item_name,
item_details.description,
"Inactive",
self.name,
batch_no,
)
for batch_no, numbers in groups.items():
SerialBatchIdentity("Serial No").resolve(
self.production_item,
numbers,
create=True,
defaults={"company": self.company, "work_order": self.name, "batch_no": batch_no},
)
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
def validate_cancel(self):
if self.status == "Stopped":
frappe.throw(_("Stopped Work Order cannot be cancelled, Unstop it first to cancel"))

View File

@@ -520,3 +520,5 @@ erpnext.patches.v16_0.add_transaction_roles_to_sms_settings
erpnext.patches.v16_0.set_secondary_item_valuation_type
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
erpnext.patches.v16_0.set_supplier_quotation_order_status
erpnext.patches.separate_serial_batch_identity

View File

@@ -0,0 +1,10 @@
import frappe
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
def execute():
# Reload also drops the former single-field unique indexes on both database engines.
for doctype in ("Serial No", "Batch"):
frappe.reload_doc("stock", "doctype", frappe.scrub(doctype), force=True)
SerialBatchIdentity(doctype).sync_constraint()

View File

@@ -6,6 +6,8 @@ import "./utils/party";
import "./utils/draft_link_guard";
import "./controllers/stock_controller";
import "./utils/serial_no_batch_selector";
import "./utils/serial_batch_input";
import "./utils/serial_batch_display";
import "./utils/serial_batch_inline_editor";
import "./payment/payments";
import "./templates/visual_plant_floor_template.html";

View File

@@ -0,0 +1,176 @@
/* eslint-env node */
const assert = require("node:assert/strict");
const { readFileSync } = require("node:fs");
const path = require("node:path");
const { test } = require("node:test");
const vm = require("node:vm");
function setupScanner() {
const items = [];
const item_requests = [];
const alerts = [];
let scan_result;
const frm = {
doctype: "Delivery Note",
doc: { doctype: "Delivery Note", items },
fields_dict: {
scan_barcode: {
value: "",
set_value(value) {
this.value = value;
},
},
items: { grid: { doctype: "Delivery Note Item" } },
},
script_manager: { trigger() {} },
};
const frappe = {
flags: {},
meta: { has_field: (doctype, field) => field !== "last_scanned_warehouse" },
utils: { get_link_title: () => "PHYSICAL-123", add_link_title() {} },
call: async () => ({ message: scan_result }),
show_alert: (alert) => alerts.push(alert),
run_serially: (tasks) =>
tasks.reduce((pending, task) => pending.then(task), Promise.resolve()),
model: {
add_child: (doc, doctype) => {
const row = {
doctype,
name: `row-${items.length + 1}`,
idx: items.length + 1,
qty: 1,
};
items.push(row);
return row;
},
set_value: async (doctype, name, field, value) => {
const row = items.find((item) => item.name === name);
const values = typeof field === "string" ? { [field]: value } : field;
const item_changed =
values.item_code && values.item_code !== row.item_code;
// Frappe sets all supplied fields before running their change handlers.
Object.assign(row, values);
if (item_changed) {
item_requests.push({ ...row });
// Outward item selection auto-picks stock if the scanned references were absent.
row.serial_no ||= "auto-picked-id";
row.batch_no ||= "auto-picked-batch";
}
},
},
};
const context = {
frappe,
erpnext: { utils: {} },
__: (text) => text,
refresh_field() {},
flt: Number,
};
vm.runInNewContext(
readFileSync(path.join(__dirname, "../utils/barcode_scanner.js"), "utf8"),
context
);
const scanner = new context.erpnext.utils.BarcodeScanner({ frm });
const scan = (serial_no, item_code = "ITEM-A") => {
scan_result = {
item_code,
serial_no,
serial_number: "PHYSICAL-123",
batch_no: `batch-${item_code}`,
has_serial_no: 1,
has_batch_no: 1,
};
frm.fields_dict.scan_barcode.value = "PHYSICAL-123";
return scanner.process_scan();
};
return { scanner, scan, items, item_requests, alerts, frm, frappe };
}
test("one scan sets its serial and batch before item auto-selection and adds one unit", async () => {
const { scan, items, item_requests } = setupScanner();
await scan("scanned-id");
assert.equal(item_requests[0].serial_no, "scanned-id");
assert.equal(item_requests[0].batch_no, "batch-ITEM-A");
assert.equal(item_requests[0].qty, 1);
assert.equal(items[0].serial_no, "scanned-id");
assert.equal(items[0].qty, 1);
});
test("rescanning the same serial does not append it or increase quantity", async () => {
const { scan, items, alerts } = setupScanner();
await scan("scanned-id");
await assert.rejects(scan("scanned-id"));
assert.equal(items.length, 1);
assert.equal(items[0].serial_no, "scanned-id");
assert.equal(items[0].qty, 1);
assert.equal(alerts.at(-1).indicator, "orange");
});
test("distinct serial IDs, including prefixes, each add one unit", async () => {
const { scan, items } = setupScanner();
await scan("scanned-id-long");
await scan("scanned-id");
assert.equal(items[0].serial_no, "scanned-id-long\nscanned-id");
assert.equal(items[0].qty, 2);
});
test("matching physical numbers on different items keep their separate serial IDs", async () => {
const { scan, items } = setupScanner();
await scan("item-a-id", "ITEM-A");
await scan("item-b-id", "ITEM-B");
assert.deepEqual(
items.map((row) => [row.item_code, row.serial_no, row.qty]),
[
["ITEM-A", "item-a-id", 1],
["ITEM-B", "item-b-id", 1],
]
);
});
test("scanning into an empty default row starts with one unit", async () => {
const { scan, items, frappe, frm } = setupScanner();
frappe.model.add_child(frm.doc, "Delivery Note Item");
await scan("scanned-id");
assert.equal(items.length, 1);
assert.equal(items[0].serial_no, "scanned-id");
assert.equal(items[0].qty, 1);
});
test("the scan dialog replaces auto-selected serials with its scanned list", async () => {
const { scanner, items, frappe, frm } = setupScanner();
frappe.ui = {
Dialog: class {
constructor({ fields }) {
this.values = Object.fromEntries(
fields.map((field) => [field.fieldname, field.default])
);
this.$wrapper = { find: () => ({ css() {} }) };
}
set_primary_action(label, action) {
this.primary_action = action;
}
get_value(field) {
return this.values[field];
}
show() {}
hide() {}
},
};
const row = frappe.model.add_child(frm.doc, "Delivery Note Item");
Object.assign(row, {
item_code: "ITEM-A",
serial_no: "auto-picked-id",
batch_no: "batch-ITEM-A",
});
scanner.prepare_item_for_scan(
row,
"ITEM-A",
null,
"batch-ITEM-A",
"scanned-id"
);
await scanner.dialog.primary_action();
assert.equal(items[0].serial_no, "scanned-id");
assert.equal(items[0].qty, 1);
assert.equal(items[0].has_item_scanned, 1);
});

View File

@@ -0,0 +1,256 @@
/* eslint-env node */
const assert = require("node:assert/strict");
const { readFileSync } = require("node:fs");
const path = require("node:path");
const { test } = require("node:test");
const vm = require("node:vm");
function setup(xcall) {
class Control {
get_model_value() {
return this.doc.serial_no;
}
parse_validate_and_set_in_model(value) {
this.doc.serial_no = value;
}
set_formatted_input(value) {
this.display = value;
}
set_disp_area() {}
}
const titles = new Map();
const handlers = {};
const frappe = {
xcall,
ui: {
form: {
ControlSmallText: Control,
ControlLink: Control,
ControlText: Control,
ControlLongText: Control,
on: (doctype, events) => {
handlers[doctype] = events;
},
},
},
utils: {
get_link_title: (doctype, name) => titles.get(name),
add_link_title: (doctype, name, value) => titles.set(name, value),
},
};
vm.runInNewContext(
readFileSync(
path.join(__dirname, "../utils/serial_batch_input.js"),
"utf8"
),
{ frappe }
);
const control = new frappe.ui.form.ControlSmallText();
control.frm = {
doctype: "Purchase Receipt",
doc: { doctype: "Purchase Receipt" },
};
control.df = { parent: "Purchase Receipt Item", fieldname: "serial_no" };
control.doc = { item_code: "ITEM-B", serial_no: "existing-id" };
return { control, handlers, titles, frappe };
}
test("physical input resolves using the row's item, even when it resembles an existing ID", async () => {
let request;
const { control } = setup(async (method, args) => {
request = args;
return ["new-id"];
});
await control.parse_validate_and_set_in_model("existing-id", {});
assert.equal(request.row.item_code, "ITEM-B");
assert.deepEqual(Array.from(request.numbers), ["existing-id"]);
assert.equal(control.doc.serial_no, "new-id");
assert.equal(control.serial_number_text("new-id"), "existing-id");
});
test("programmatic ID updates do not resolve the ID as a physical number", async () => {
const { control } = setup(() => {
throw new Error("Unexpected lookup");
});
await control.parse_validate_and_set_in_model("another-id", null);
assert.equal(control.doc.serial_no, "another-id");
});
test("saving waits for number resolution and stale responses cannot overwrite newer input", async () => {
const responses = [];
const { control, handlers } = setup(
() => new Promise((resolve) => responses.push(resolve))
);
const first = control.parse_validate_and_set_in_model("first-number", {});
const second = control.parse_validate_and_set_in_model("second-number", {});
let saved = false;
const saving = handlers[control.frm.doctype]
.before_save(control.frm)
.then(() => {
saved = true;
});
assert.equal(saved, false);
responses[1](["second-id"]);
await second;
responses[0](["first-id"]);
await first;
await saving;
assert.equal(control.doc.serial_no, "second-id");
assert.equal(saved, true);
});
test("an ambiguous scan requires a selection and cancel leaves it unresolved", async () => {
let dialog;
const context = {
erpnext: { utils: {} },
__: (text) => text,
frappe: {
ui: {
Dialog: class {
constructor(options) {
Object.assign(this, options);
dialog = this;
}
show() {}
hide() {
this.onhide();
}
},
},
},
};
vm.runInNewContext(
readFileSync(path.join(__dirname, "../utils/barcode_scanner.js"), "utf8"),
context
);
const scanner = Object.create(context.erpnext.utils.BarcodeScanner.prototype);
const candidates = [
{ item_code: "A", barcode: "123" },
{ item_code: "A", serial_no: "id-a", serial_number: "123" },
{ item_code: "B", serial_no: "id-b", serial_number: "123" },
];
const selection = scanner.select_scan_match(candidates);
const item_field = dialog.fields[0];
assert.equal(item_field.fieldtype, "Link");
assert.equal(item_field.options, "Item");
assert.deepEqual(Array.from(item_field.get_query().filters.name[1]), [
"A",
"B",
]);
dialog.primary_action({ item_code: "B" });
assert.equal(await selection, candidates[2]);
assert.equal(
await scanner.select_scan_match(candidates.slice(0, 2)),
candidates[1]
);
const cancelled = scanner.select_scan_match(candidates);
dialog.hide();
assert.equal(await cancelled, null);
});
test("a programmatic update invalidates a pending keyboard lookup", async () => {
let resolve;
const { control } = setup(
() =>
new Promise((callback) => {
resolve = callback;
})
);
const typing = control.parse_validate_and_set_in_model("typed-number", {});
await control.parse_validate_and_set_in_model("selected-id", null);
resolve(["typed-id"]);
await typing;
assert.equal(control.doc.serial_no, "selected-id");
});
test("typed batch input uses the physical label even when the control mapped it to an old ID", async () => {
let request;
const { control, frappe } = setup(async (method, args) => {
request = args;
return { batch_nos: ["item-b-batch-id"] };
});
const link = new frappe.ui.form.ControlLink();
link.frm = control.frm;
link.doc = control.doc;
link.get_options = () => "Batch";
link.get_label_value = () => "physical-batch-number";
await link.parse_validate_and_set_in_model("item-a-batch-id", {}, undefined);
assert.equal(request.item_code, "ITEM-B");
assert.deepEqual(Array.from(request.batch_numbers), [
"physical-batch-number",
]);
assert.equal(link.doc.serial_no, "item-b-batch-id");
await link.parse_validate_and_set_in_model("programmatic-id", null);
assert.equal(link.doc.serial_no, "programmatic-id");
await link.parse_validate_and_set_in_model(
"item-a-batch-id",
null,
"selected-physical-label"
);
assert.deepEqual(Array.from(request.batch_numbers), [
"selected-physical-label",
]);
});
test("POS controls use their explicit item and form context", async () => {
let request;
const { control } = setup(async (method, args) => {
request = args;
return ["pos-serial-id"];
});
control.serial_batch_context = { frm: control.frm, row: control.doc };
control.frm = undefined;
await control.parse_validate_and_set_in_model("POS-PHYSICAL", {});
assert.equal(request.row.item_code, "ITEM-B");
assert.equal(control.doc.serial_no, "pos-serial-id");
});
test("report numbers display physical labels and link to internal IDs", () => {
let linked;
const context = {
frappe: {
model: { can_read: () => true },
form: {
formatters: {
Link: (id, df, options) => {
linked = { id, options };
return options.label;
},
},
},
utils: {
escape_html: (value) =>
value.replaceAll("<", "&lt;").replaceAll(">", "&gt;"),
},
},
};
vm.runInNewContext(
readFileSync(
path.join(__dirname, "../utils/serial_batch_display.js"),
"utf8"
),
context
);
const format = context.frappe.form.formatters.SerialBatchNumber;
const field = { reference_field: "serial_no", options: "Serial No" };
assert.equal(
format("PHYSICAL-123", field, {}, { serial_no: "internal-id" }),
"PHYSICAL-123"
);
assert.equal(linked.id, "internal-id");
assert.equal(linked.options.label, "PHYSICAL-123");
context.frappe.model.can_read = () => false;
assert.equal(
format("PHYSICAL-123", field, {}, { serial_no: "internal-id" }),
"PHYSICAL-123"
);
assert.equal(
format(
"<serial>",
field,
{ for_print: true },
{ serial_no: "internal-id" }
),
"&lt;serial&gt;"
);
});

View File

@@ -55,8 +55,16 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
return;
}
this.scan_api_call(input, (r) => {
const data = r && r.message;
this.scan_api_call(input, async (r) => {
let data = r && r.message;
if (data?.candidates) {
data = await this.select_scan_match(data.candidates);
if (!data) {
this.clean_up();
resolve();
return;
}
}
if (
!data ||
Object.keys(data).length === 0 ||
@@ -95,24 +103,81 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
});
}
scan_api_call(input, callback) {
select_scan_match(candidates) {
const item_codes = [...new Set(candidates.map((candidate) => candidate.item_code))];
if (item_codes.length <= 1) {
return Promise.resolve(this.get_scan_match(candidates, item_codes[0]));
}
return new Promise((resolve) => {
let selected = false;
const dialog = new frappe.ui.Dialog({
title: __("Select Item"),
size: "small",
fields: [
{
fieldname: "item_code",
label: __("Item"),
fieldtype: "Link",
options: "Item",
only_select: 1,
get_query: () => ({ filters: { name: ["in", item_codes] } }),
filter_description: __("Items matching the scanned number"),
reqd: 1,
},
],
primary_action_label: __("Select"),
primary_action: ({ item_code }) => {
const match = this.get_scan_match(candidates, item_code);
if (!match) return;
selected = true;
dialog.hide();
resolve(match);
},
onhide: () => {
if (!selected) resolve(null);
},
});
dialog.show();
});
}
get_scan_match(candidates, item_code) {
const matches = candidates.filter((candidate) => candidate.item_code === item_code);
// Keep the serial reference when the same item's barcode or batch number also matches.
return (
matches.find((match) => match.serial_no) || matches.find((match) => match.batch_no) || matches[0]
);
}
scan_api_call(input, callback, item_code) {
frappe
.call({
method: this.scan_api,
args: {
search_value: input,
allow_multiple: true,
ctx: {
item_code,
set_warehouse: this.frm.doc.set_warehouse,
company: this.frm.doc.company,
},
},
})
.then((r) => {
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);
}
callback(r);
});
}
update_table(data) {
if (data.has_serial_no && data.batch_no && !data.serial_no) {
frappe.msgprint(__("Please scan a serial number for Item {0}", [data.item_code]));
return Promise.reject();
}
return new Promise((resolve, reject) => {
let cur_grid = this.frm.fields_dict[this.items_table_name].grid;
frappe.flags.trigger_from_barcode_scanner = true;
@@ -180,9 +245,15 @@ 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 item_data = { item_code: item_code, use_serial_batch_fields: 1.0 };
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
);
frappe.flags.trigger_from_barcode_scanner = true;
item_data[this.qty_field] = Number(row[this.qty_field] || 0) + Number(value);
item_data[this.qty_field] =
Number((row.item_code && row[this.qty_field]) || 0) + Number(value);
await frappe.model.set_value(row.doctype, row.name, item_data);
return value;
};
@@ -199,6 +270,18 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
});
}
get_scanned_item_values(row, item_code, batch_no, serial_no) {
// 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 (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) {
values[this.batch_no_field] = batch_no;
}
return values;
}
prepare_item_for_scan(row, item_code, barcode, batch_no, serial_no) {
var me = this;
this.dialog = new frappe.ui.Dialog({
@@ -206,19 +289,23 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
fields: me.get_fields_for_dialog(row, item_code, barcode, batch_no, serial_no),
});
this.dialog.set_primary_action(__("Update"), () => {
const item_data = { item_code: item_code };
this.dialog.set_primary_action(__("Update"), async () => {
const item_data = this.get_scanned_item_values(
row,
item_code,
this.dialog.get_value("batch_no"),
this.dialog.get_value("serial_no")
);
item_data[this.qty_field] = this.dialog.get_value("scanned_qty");
item_data["has_item_scanned"] = 1;
this.remaining_qty =
flt(this.dialog.get_value("qty")) - flt(this.dialog.get_value("scanned_qty"));
frappe.model.set_value(row.doctype, row.name, item_data);
await frappe.model.set_value(row.doctype, row.name, item_data);
frappe.run_serially([
await frappe.run_serially([
() => this.set_batch_no(row, this.dialog.get_value("batch_no")),
() => this.set_barcode(row, this.dialog.get_value("barcode")),
() => this.set_serial_no(row, this.dialog.get_value("serial_no")),
() => this.add_child_for_remaining_qty(row),
() => this.clean_up(),
]);
@@ -245,11 +332,15 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
}
if (e.target.value) {
this.scan_api_call(e.target.value, (r) => {
if (r.message) {
this.update_dialog_values(item_code, r);
}
});
this.scan_api_call(
e.target.value,
async (r) => {
if (r.message?.candidates)
r.message = await this.select_scan_match(r.message.candidates);
if (r.message) this.update_dialog_values(item_code, r);
},
item_code
);
}
},
},
@@ -282,7 +373,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
fields.push({
fieldtype: "Link",
fieldname: "batch_no",
options: "Batch No",
options: "Batch",
label: __("Batch No"),
default: batch_no,
read_only: 1,
@@ -297,6 +388,17 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
label: __("Serial Nos"),
default: serial_no,
read_only: 1,
hidden: 1,
});
}
if (serial_no) {
fields.push({
fieldtype: "Small Text",
fieldname: "serial_numbers",
label: __("Serial Nos"),
default: frappe.utils.get_link_title("Serial No", serial_no) || serial_no,
read_only: 1,
});
}
@@ -316,7 +418,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
}
update_dialog_values(scanned_item, r) {
const { item_code, barcode, batch_no, serial_no } = r.message;
const { item_code, barcode, batch_no, serial_no, serial_number } = r.message;
this.dialog.set_value("barcode_scanner", "");
if (
@@ -331,6 +433,10 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
this.validate_duplicate_serial_no(serial_no);
let serial_nos = this.dialog.get_value("serial_no") + "\n" + serial_no;
this.dialog.set_value("serial_no", serial_nos);
this.dialog.set_value(
"serial_numbers",
this.dialog.get_value("serial_numbers") + "\n" + (serial_number || serial_no)
);
}
let qty = flt(this.dialog.get_value("scanned_qty")) + 1.0;
@@ -382,18 +488,17 @@ 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)) {
const existing_serial_nos = row[this.serial_no_field];
let new_serial_nos = "";
if (!!existing_serial_nos) {
new_serial_nos = existing_serial_nos + "\n" + serial_no;
} else {
new_serial_nos = serial_no;
}
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);
}
}
merge_serial_nos(existing, added) {
return [...new Set(`${existing || ""}\n${added || ""}`.split("\n").map((id) => id.trim()))]
.filter(Boolean)
.join("\n");
}
async set_barcode_uom(row, uom) {
// e.g. Pick List: picked_qty is always tracked in stock UOM, so an incidental
// barcode uom must not overwrite the row's own uom.
@@ -437,10 +542,11 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
}
is_duplicate_serial_no(row, serial_no) {
const is_duplicate = row[this.serial_no_field]?.includes(serial_no);
const is_duplicate = serial_no && row[this.serial_no_field]?.split("\n").includes(serial_no);
if (is_duplicate) {
this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange");
const number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no;
this.show_alert(__("Serial No {0} is already added", [number]), "orange");
}
return is_duplicate;
}

View File

@@ -0,0 +1,19 @@
// Reports export physical numbers and retain a separate ID field for each link.
frappe.form.formatters.SerialBatchNumber = (value, df, options, doc) => {
if (!value) return "";
const labels = String(value).split("\n");
const ids = String(doc?.[df.reference_field] || "").split("\n");
return labels
.map((label, index) => {
if (
!ids[index] ||
options?.for_print ||
options?.only_value ||
!frappe.model.can_read(df.options)
) {
return frappe.utils.escape_html(label);
}
return frappe.form.formatters.Link(ids[index], df, { ...options, label }, doc);
})
.join("<br>");
};

View File

@@ -469,7 +469,10 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
$td.data("editing", 1);
let name = $td.data("name");
let current = $td.text().trim();
let current =
this.pending.updates[name]?.[opts.field] ||
this.last_entries.find((row) => row.name === name)?.[opts.field] ||
"";
$td.empty().addClass("sbie-input-cell").css("cursor", "default");
this.wrapper.find(".sbie-table").css("overflow", "visible");
@@ -636,7 +639,9 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
for (const row of rows) {
p.new_entries.push({
serial_no: row.serial_no || "",
serial_number: row.serial_number,
batch_no: row.batch_no || "",
batch_number: row.batch_number,
qty: Math.abs(flt(row.qty)) || 1,
});
}
@@ -696,18 +701,24 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
let p = this.pending;
if (p.delete_all) return null;
return this.last_entries.find((d) => d[field] === value && !p.deleted.some((x) => x.name === d.name));
return this.last_entries.find(
(d) =>
(d[field === "batch_no" ? "batch_number" : "serial_number"] || d[field]) === value &&
!p.deleted.some((x) => x.name === d.name)
);
}
get_known_identifiers() {
let p = this.pending;
let known = new Set(p.new_entries.map((d) => d.serial_no || d.batch_no));
let known = new Set(
p.new_entries.map((d) => d.serial_number || d.serial_no || d.batch_number || d.batch_no)
);
if (!p.delete_all) {
let deleted = new Set(p.deleted.map((d) => d.name));
for (const d of this.last_entries) {
if (!deleted.has(d.name)) {
known.add(d.serial_no || d.batch_no);
known.add(d.serial_number || d.serial_no || d.batch_number || d.batch_no);
}
}
}
@@ -727,9 +738,9 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
return false;
}
p.new_entries.push({ serial_no: value, batch_no: "", qty: 1 });
p.new_entries.push({ serial_number: value, qty: 1 });
} else {
let existing = p.new_entries.find((d) => d.batch_no === value);
let existing = p.new_entries.find((d) => (d.batch_number || d.batch_no) === value);
let server_row = this.get_active_server_row("batch_no", value);
if (existing) {
existing.qty = flt(existing.qty) + 1;
@@ -738,7 +749,7 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
let current = update && update.qty != null ? flt(update.qty) : Math.abs(flt(server_row.qty));
this.update_entry(server_row.name, { qty: current + 1 });
} else {
p.new_entries.push({ serial_no: "", batch_no: value, qty: 1 });
p.new_entries.push({ batch_number: value, qty: 1 });
}
}
@@ -788,7 +799,7 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
let added = 0;
for (const serial_no of serial_nos) {
if (known.has(serial_no)) continue;
p.new_entries.push({ serial_no: serial_no, batch_no: "", qty: 1 });
p.new_entries.push({ serial_number: serial_no, qty: 1 });
added++;
}
@@ -935,8 +946,16 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
.map((d, i) => {
let update = p.updates[d.name] || {};
let qty = update.qty != null ? flt(update.qty) : Math.abs(flt(d.qty));
let batch_no = this.esc(update.batch_no || d.batch_no || "");
let serial_no = this.esc(update.serial_no || d.serial_no || "");
let batch_no = this.esc(
update.batch_no
? frappe.utils.get_link_title("Batch", update.batch_no) || update.batch_no
: d.batch_number || d.batch_no || ""
);
let serial_no = this.esc(
update.serial_no
? frappe.utils.get_link_title("Serial No", update.serial_no) || update.serial_no
: d.serial_number || d.serial_no || ""
);
let name = this.esc(d.name);
return `<tr data-name="${name}">
@@ -957,8 +976,12 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
)}" style="cursor: pointer;">${batch_no}</td>`
: ""
}
<td class="${!d.serial_no && show_batch ? "sbie-input-cell" : ""}" style="text-align: right;">${
!d.serial_no && show_batch ? this.get_qty_input(d, qty) : this.format_float(qty)
<td class="${
!(d.serial_no || d.serial_number) && show_batch ? "sbie-input-cell" : ""
}" style="text-align: right;">${
!(d.serial_no || d.serial_number) && show_batch
? this.get_qty_input(d, qty)
: this.format_float(qty)
}</td>
</tr>`;
})
@@ -975,10 +998,30 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
<td style="text-align: center;">
<input type="checkbox" class="sbie-check" data-pending-index="${index}"></td>
<td style="text-align: center;">${base_count + index + 1}</td>
${show_serial ? `<td>${this.esc(d.serial_no || "")}</td>` : ""}
${show_batch ? `<td>${this.esc(d.batch_no || "")}</td>` : ""}
<td class="${!d.serial_no && show_batch ? "sbie-input-cell" : ""}" style="text-align: right;">${
!d.serial_no && show_batch
${
show_serial
? `<td>${this.esc(
d.serial_number ||
frappe.utils.get_link_title("Serial No", d.serial_no) ||
d.serial_no ||
""
)}</td>`
: ""
}
${
show_batch
? `<td>${this.esc(
d.batch_number ||
frappe.utils.get_link_title("Batch", d.batch_no) ||
d.batch_no ||
""
)}</td>`
: ""
}
<td class="${
!(d.serial_no || d.serial_number) && show_batch ? "sbie-input-cell" : ""
}" style="text-align: right;">${
!(d.serial_no || d.serial_number) && show_batch
? this.get_pending_qty_input(d, index)
: this.format_float(d.qty)
}</td>

View File

@@ -0,0 +1,165 @@
// Resolve physical input before updating serial and batch links in the form model.
const registered_forms = new Set();
const serial_list_fields = new Set(["serial_no", "rejected_serial_no", "current_serial_no"]);
const with_serial_numbers = (BaseControl) =>
class extends BaseControl {
number_context() {
return this.serial_batch_context || { frm: this.frm, row: this.doc };
}
is_serial_list() {
const { frm, row } = this.number_context();
return (
frm &&
this.df.parent !== "Serial No" &&
serial_list_fields.has(this.df.fieldname) &&
(row?.item_code || row?.rm_item_code)
);
}
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 { frm, row } = this.number_context();
const item_code = row.item_code || row.rm_item_code;
const pending = (async () => {
const numbers = (value || "")
.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 }
)
: [];
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]));
return super.parse_validate_and_set_in_model(ids.join("\n"), event);
})();
track_number_request(frm, pending);
try {
return await pending;
} catch (error) {
if (revision === this.number_revision) this.set_formatted_input(this.get_model_value());
throw error;
} finally {
frm.serial_number_requests.delete(pending);
}
}
serial_number_text(value) {
return (value || "")
.split("\n")
.map((id) => frappe.utils.get_link_title("Serial No", id) || id)
.join("\n");
}
async load_serial_titles(value) {
const missing = (value || "")
.split("\n")
.filter((id) => id && !frappe.utils.get_link_title("Serial No", id));
if (!missing.length) return;
if (this.title_request_value !== value) {
this.title_request_value = value;
this.title_request = frappe.xcall(
"erpnext.stock.serial_batch_identity.get_serial_batch_labels",
{
doctype: "Serial No",
names: missing,
}
);
}
const labels = await this.title_request;
Object.entries(labels).forEach(([id, label]) =>
frappe.utils.add_link_title("Serial No", id, label)
);
}
set_formatted_input(value) {
if (!this.is_serial_list()) return super.set_formatted_input(value);
super.set_formatted_input(this.serial_number_text(value));
this.load_serial_titles(value).then(() => {
if (this.get_model_value() === value && !this.$input?.is(":focus")) {
super.set_formatted_input(this.serial_number_text(value));
}
});
}
set_disp_area(value) {
if (!this.is_serial_list()) return super.set_disp_area(value);
if (this.disp_area) $(this.disp_area).text(this.serial_number_text(value));
this.load_serial_titles(value).then(() => {
if (this.disp_area && this.get_model_value() === value) {
$(this.disp_area).text(this.serial_number_text(value));
}
});
}
};
frappe.ui.form.ControlSmallText = with_serial_numbers(frappe.ui.form.ControlSmallText);
frappe.ui.form.ControlText = with_serial_numbers(frappe.ui.form.ControlText);
frappe.ui.form.ControlLongText = with_serial_numbers(frappe.ui.form.ControlLongText);
frappe.ui.form.ControlLink = class extends frappe.ui.form.ControlLink {
async parse_validate_and_set_in_model(value, event, label) {
const revision = (this.number_revision = (this.number_revision || 0) + 1);
const doctype = this.get_options();
const { frm, row } = this.serial_batch_context || { frm: this.frm, row: this.doc };
const item_code = row?.item_code || row?.rm_item_code || row?.item;
if (
!frm ||
!item_code ||
!["Serial No", "Batch"].includes(doctype) ||
(!event && label === undefined)
) {
return super.parse_validate_and_set_in_model(value, event, label);
}
// Autocomplete supplies the selected physical label; change/blur supplies typed text.
const number = (label ?? this.get_label_value()).trim();
const pending = (async () => {
let name = "";
if (number) {
const serial = doctype === "Serial No";
const result = await frappe.xcall(
"erpnext.stock.serial_batch_identity.resolve_serial_batch_numbers",
{
item_code,
[serial ? "serial_numbers" : "batch_numbers"]: [number],
}
);
name = result[serial ? "serial_nos" : "batch_nos"][0];
}
if (
revision !== this.number_revision ||
item_code !== (row?.item_code || row?.rm_item_code || row?.item)
)
return;
return super.parse_validate_and_set_in_model(name, event, number);
})();
track_number_request(frm, pending);
try {
return await pending;
} catch (error) {
if (revision === this.number_revision) this.set_formatted_input(this.get_model_value());
throw error;
} finally {
frm.serial_number_requests.delete(pending);
}
}
};
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 });
}
frm.serial_number_requests ||= new Set();
frm.serial_number_requests.add(pending);
}

View File

@@ -594,14 +594,15 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
batch_no: scan_batch_no,
},
callback: (r) => {
this.update_serial_batch_no();
this.update_serial_batch_no(r.message);
},
});
}
}
update_serial_batch_no() {
const { scan_serial_no, scan_batch_no } = this.dialog.get_values();
update_serial_batch_no(result) {
const scan_serial_no = result.serial_nos?.[0];
const scan_batch_no = result.batch_nos?.[0];
if (scan_serial_no) {
let existing_row = this.dialog.fields_dict.entries.df.data.filter((d) => {

View File

@@ -16,16 +16,22 @@ from erpnext.stock.utils import scan_barcode
def search_by_term(search_term, warehouse, price_list):
result = search_for_serial_or_batch_or_barcode_number(search_term) or {}
result = scan_barcode(search_term, allow_multiple=True)
if not result or result.get("warehouse"):
return
matches = result.get("candidates", [result])
return {
"items": [get_scanned_item(match, warehouse, price_list) for match in matches],
"requires_selection": len(matches) > 1,
}
item_code = result.get("item_code", search_term)
def get_scanned_item(result, warehouse, price_list):
item_code = result["item_code"]
serial_no = result.get("serial_no", "")
batch_no = result.get("batch_no", "")
barcode = result.get("barcode", "")
if not result:
return
item_doc = frappe.get_doc("Item", item_code)
if not item_doc:
@@ -109,7 +115,7 @@ def search_by_term(search_term, warehouse, price_list):
}
)
return {"items": [item]}
return item
def filter_result_items(result, pos_profile):
@@ -271,8 +277,10 @@ def get_items(
@frappe.whitelist()
def search_for_serial_or_batch_or_barcode_number(search_value: str) -> dict[str, str | None]:
return scan_barcode(search_value)
def search_for_serial_or_batch_or_barcode_number(
search_value: str, item_code: str | None = None, allow_multiple: bool = False
) -> dict:
return scan_barcode(search_value, {"item_code": item_code}, allow_multiple=allow_multiple)
def get_conditions(search_term, item=None):

View File

@@ -199,6 +199,7 @@ erpnext.PointOfSale.ItemDetails = class {
parent: this.$form_container.find(`.${fieldname}-control`),
render_input: true,
});
this[`${fieldname}_control`].serial_batch_context = { frm: this.events.get_frm(), row: item };
this[`${fieldname}_control`].set_value(item[fieldname]);
});

View File

@@ -435,32 +435,14 @@ erpnext.PointOfSale.ItemSelector = class {
filter_items({ search_term = "" } = {}) {
this.start_item_loading_animation();
const selling_price_list = this.events.get_frm().doc.selling_price_list;
if (search_term) {
search_term = search_term.toLowerCase();
// memoize
this.search_index = this.search_index || {};
this.search_index[selling_price_list] = this.search_index[selling_price_list] || {};
if (this.search_index[selling_price_list][search_term]) {
const items = this.search_index[selling_price_list][search_term];
this.items = items;
this.render_item_list(items);
this.auto_add_item &&
this.search_field.$input[0].value &&
this.items.length == 1 &&
this.add_filtered_item_to_cart();
return;
}
}
this.get_items({ search_term })
.then(({ message }) => {
// eslint-disable-next-line no-unused-vars
const { items, serial_no, batch_no, barcode } = message;
if (search_term && !barcode) {
this.search_index[selling_price_list][search_term] = items;
const { items, requires_selection } = message;
if (requires_selection) {
frappe.show_alert({
message: __("Select the item that matches the scanned number."),
indicator: "blue",
});
}
this.items = items;
this.render_item_list(items);

View File

@@ -1,7 +1,7 @@
{
"actions": [],
"allow_import": 1,
"autoname": "field:batch_id",
"autoname": "hash",
"creation": "2013-03-05 14:50:38",
"doctype": "DocType",
"document_type": "Setup",
@@ -44,16 +44,15 @@
"report_hide": 1
},
{
"depends_on": "eval:doc.__islocal",
"fieldname": "batch_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Batch ID",
"label": "Batch No",
"no_copy": 1,
"oldfieldname": "batch_id",
"oldfieldtype": "Data",
"reqd": 1,
"unique": 1
"search_index": 1
},
{
"fieldname": "item",
@@ -219,7 +218,7 @@
"modified_by": "Administrator",
"module": "Stock",
"name": "Batch",
"naming_rule": "By fieldname",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
@@ -294,5 +293,7 @@
"sort_order": "DESC",
"states": [],
"title_field": "batch_id",
"track_changes": 1
"track_changes": 1,
"show_title_field_in_link": 1,
"search_fields": "batch_id,item"
}

View File

@@ -12,12 +12,14 @@ from frappe.model.naming import make_autoname, revert_series_if_last
from frappe.utils import cint, flt, get_link_to_form
from frappe.utils.data import DateTimeLikeObject, add_days
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
class UnableToSelectBatchError(frappe.ValidationError):
pass
def get_name_from_hash():
def get_name_from_hash(item_code=None):
"""
Get a name for a Batch by generating a unique hash.
:return: The hash that was generated.
@@ -25,7 +27,7 @@ def get_name_from_hash():
temp = None
while not temp:
temp = frappe.generate_hash()[:7].upper()
if frappe.db.exists("Batch", temp):
if frappe.db.exists("Batch", {"batch_id": temp, **({"item": item_code} if item_code else {})}):
temp = None
return temp
@@ -114,11 +116,10 @@ class Batch(Document):
use_batchwise_valuation: DF.Check
# end: auto-generated types
def autoname(self):
"""Generate random ID for batch if not specified"""
def before_naming(self):
"""Generate a physical batch number when none was entered."""
if self.batch_id:
self.name = self.batch_id
return
create_new_batch, batch_number_series = frappe.db.get_value(
@@ -126,7 +127,7 @@ class Batch(Document):
)
if not create_new_batch:
frappe.throw(_("Batch ID is mandatory"), frappe.MandatoryError)
frappe.throw(_("Batch No is mandatory"), frappe.MandatoryError)
while not self.batch_id:
if batch_number_series:
@@ -134,21 +135,22 @@ class Batch(Document):
elif batch_uses_naming_series():
self.batch_id = self.get_name_from_naming_series()
else:
self.batch_id = get_name_from_hash()
self.batch_id = get_name_from_hash(self.item)
# User might have manually created a batch with next number
if frappe.db.exists("Batch", self.batch_id):
if frappe.db.exists("Batch", {"item": self.item, "batch_id": self.batch_id}):
self.batch_id = None
self.name = self.batch_id
def onload(self):
self.image = frappe.db.get_value("Item", self.item, "image")
def after_delete(self):
revert_series_if_last(get_batch_naming_series(), self.name)
revert_series_if_last(get_batch_naming_series(), self.batch_id)
def validate(self):
SerialBatchIdentity("Batch").validate(self)
if not self.is_new() and frappe.db.get_value("Batch", self.name, "item") != self.item:
frappe.throw(_("Item cannot be changed for an existing Batch"))
self.item_has_batch_enabled()
self.set_batchwise_valuation()
@@ -478,3 +480,7 @@ def get_batch_no(bundle_id):
batches[batch_id] += abs(d.get("qty"))
return batches
def on_doctype_update():
SerialBatchIdentity("Batch").sync_constraint()

View File

@@ -482,7 +482,7 @@ class TestBatch(ERPNextTestSuite):
if not frappe.db.exists("Batch", batch_name):
batch = frappe.get_doc(doctype="Batch", item=item_name, batch_id=batch_name).insert(
ignore_permissions=True
ignore_permissions=True, set_name=batch_name
)
batch.save()
@@ -531,14 +531,15 @@ class TestBatch(ERPNextTestSuite):
frappe.set_value("Stock Settings", "Stock Settings", "use_naming_series", 1)
batch = self.make_new_batch("_Test Stock Item For Batch Test1")
batch_name = batch.name
batch_name = batch.batch_id
self.assertNotEqual(batch.name, batch.batch_id)
self.assertTrue(batch_name.startswith("BATCH-"))
batch.delete()
batch = self.make_new_batch("_Test Stock Item For Batch Test2")
self.assertEqual(batch_name, batch.name)
self.assertEqual(batch_name, batch.batch_id)
# reset Stock Settings
if not use_naming_series:
@@ -714,7 +715,12 @@ class TestBatch(ERPNextTestSuite):
get_batch_from_bundle(pr_2.items[0].serial_and_batch_bundle),
)
self.assertEqual("BATCHEXISTING002", get_batch_from_bundle(pr_2.items[0].serial_and_batch_bundle))
self.assertEqual(
"BATCHEXISTING002",
frappe.db.get_value(
"Batch", get_batch_from_bundle(pr_2.items[0].serial_and_batch_bundle), "batch_id"
),
)
def create_batch(item_code, rate, create_item_price_for_batch):
@@ -771,6 +777,7 @@ def make_new_batch(**args):
if args.expiry_date:
batch.expiry_date = args.expiry_date
batch.insert()
# Explicit names model batches already referenced by historical transactions.
batch.insert(set_name=args.batch_id)
return batch

View File

@@ -240,7 +240,7 @@ class TestDeliveryNote(ERPNextTestSuite):
"company": "_Test Company",
}
)
sn_doc.insert()
sn_doc.insert(set_name=sn)
warehouse = "_Test Warehouse - _TC"
company = frappe.db.get_value("Warehouse", warehouse, "company")
@@ -2059,7 +2059,7 @@ class TestDeliveryNote(ERPNextTestSuite):
"company": "_Test Company",
}
)
sn_doc.insert()
sn_doc.insert(set_name=sn)
warehouse = "_Test Warehouse - _TC"
company = frappe.db.get_value("Warehouse", warehouse, "company")

View File

@@ -5,7 +5,6 @@
import frappe
from frappe import _, bold
from frappe.model.document import Document
from frappe.model.naming import NamingSeries
from frappe.query_builder import Interval
from frappe.query_builder.functions import Count, CurDate, UnixTimestamp
from frappe.utils import (
@@ -483,24 +482,6 @@ class Item(Document):
)
)
if self.is_new() and series:
obj = NamingSeries(series)
prefix = obj.get_prefix()
doctype = frappe.qb.DocType("Series")
query = frappe.qb.from_(doctype).select(doctype.name).where(doctype.name.like(f"{prefix}%"))
prefix_exists = query.run(as_dict=True)
if prefix_exists:
frappe.msgprint(
_(
"The {0} prefix '{1}' already exists. Please change the Serial No Series, otherwise you will get a Duplicate Entry error."
).format(bold(frappe.unscrub(field)), bold(prefix)),
title=_("Serial No Series Overlap"),
indicator="yellow",
alert=True,
)
def check_for_active_boms(self):
if self.default_bom:
bom_item = frappe.db.get_value("BOM", self.default_bom, "item")
@@ -641,6 +622,9 @@ class Item(Document):
frappe.db.set_value("Item", old_name, "item_name", new_name)
if merge:
from erpnext.stock.serial_batch_identity import validate_item_merge
validate_item_merge(old_name, new_name)
self.validate_properties_before_merge(new_name)
self.validate_duplicate_product_bundles_before_merge(old_name, new_name)
self.delete_old_bins(old_name)

View File

@@ -1268,9 +1268,18 @@ class TestItem(ERPNextTestSuite):
).name
serial_no = f"{item}-SN-01"
frappe.get_doc(
{"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"}
).insert()
serial_no = (
frappe.get_doc(
{
"doctype": "Serial No",
"serial_no": serial_no,
"item_code": item,
"company": "_Test Company",
}
)
.insert()
.name
)
# A draft (unsubmitted) Serial and Batch Bundle for the item must block the change.
bundle = make_serial_batch_bundle(

View File

@@ -275,7 +275,7 @@ class TestPickList(ERPNextTestSuite):
"item_code": "_Test Serialized Item",
"serial_no": serial_no,
}
).insert()
).insert(set_name=serial_no)
stock_reconciliation = frappe.get_doc(
{
@@ -1152,7 +1152,7 @@ class TestPickList(ERPNextTestSuite):
"batch_id": batch_id,
"item": item,
}
).insert()
).insert(set_name=batch_id)
make_stock_entry(
item=item,

View File

@@ -1093,7 +1093,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
"serial_no": serial_no[0],
"company": "_Test Company",
}
).insert()
).insert(set_name=serial_no[0])
pr_doc = make_purchase_receipt(item_code=item_code, qty=1, serial_no=serial_no)
pr_doc.load_from_db()
@@ -3144,6 +3144,10 @@ class TestPurchaseReceipt(ERPNextTestSuite):
"SNU-TSFISI-000014",
"SNU-TSFISI-000015",
]
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
serial_nos = SerialBatchIdentity("Serial No").resolve(item_code, serial_nos, create=True)
removed_serial = serial_nos[-1]
pr = make_purchase_receipt(
item_code=item_code,
@@ -3162,7 +3166,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
for row in sbb_doc.entries:
self.assertIn(row.serial_no, serial_nos)
serial_nos.remove("SNU-TSFISI-000015")
serial_nos.remove(removed_serial)
sr = create_stock_reconciliation(
item_code=item_code,
@@ -3191,7 +3195,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertTrue(sr.items[0].current_serial_and_batch_bundle)
self.assertTrue(sr.items[0].serial_and_batch_bundle)
serial_no_status = frappe.db.get_value("Serial No", "SNU-TSFISI-000015", "status")
serial_no_status = frappe.db.get_value("Serial No", removed_serial, "status")
self.assertNotEqual(serial_no_status, "Active")
@@ -3443,7 +3447,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
"batch_id": batch_no,
"item": batch_item,
}
).insert()
).insert(set_name=batch_no)
for serial_no in serial_nos:
if not frappe.db.exists("Serial No", serial_no):
@@ -3454,7 +3458,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
"serial_no": serial_no,
"company": "_Test Company",
}
).insert()
).insert(set_name=serial_no)
pr = make_purchase_receipt(
item_code=batch_item,
@@ -4762,7 +4766,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
"batch_id": batch_no,
"item": batch_item,
}
).insert()
).insert(set_name=batch_no)
for serial_no in serial_nos:
if not frappe.db.exists("Serial No", serial_no):
@@ -4773,7 +4777,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
"serial_no": serial_no,
"company": "_Test Company",
}
).insert()
).insert(set_name=serial_no)
pr = make_purchase_receipt(
item_code=batch_item,
@@ -5738,7 +5742,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
"batch_id": "BN-TESTDNUBVWF-00001",
"item": item_code,
}
).insert()
).insert(set_name="BN-TESTDNUBVWF-00001")
doc.db_set("use_batchwise_valuation", 0)
doc.reload()
@@ -5751,7 +5755,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
"batch_id": "BN-TESTDNUBVWF-00002",
"item": item_code,
}
).insert()
).insert(set_name="BN-TESTDNUBVWF-00002")
self.assertEqual(doc.use_batchwise_valuation, 1)
@@ -5881,7 +5885,11 @@ class TestPurchaseReceipt(ERPNextTestSuite):
).name
batch_no = "BN-TPRBWV-00001"
batch = frappe.new_doc("Batch").update({"batch_id": batch_no, "item": item_code}).insert()
batch = (
frappe.new_doc("Batch")
.update({"batch_id": batch_no, "item": item_code})
.insert(set_name=batch_no)
)
self.assertEqual(batch.use_batchwise_valuation, 1)
warehouse = "_Test Warehouse - _TC"

View File

@@ -9,9 +9,8 @@ from frappe.utils import cint, flt, parse_json
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
create_serial_batch_no_ledgers,
get_type_of_transaction,
make_batch_nos,
make_serial_nos,
)
from erpnext.stock.serial_batch_identity import add_number_labels, resolve_number_entries
SUPPORTED_VOUCHER_TYPES = frozenset(
[
@@ -36,8 +35,14 @@ def get_bundle_entries(bundle: str, start: int = 0, page_length: int = 50, searc
page_length = min(cint(page_length) or 50, 500)
table = frappe.qb.DocType("Serial and Batch Entry")
serial = frappe.qb.DocType("Serial No")
batch = frappe.qb.DocType("Batch")
query = (
frappe.qb.from_(table)
.left_join(serial)
.on(serial.name == table.serial_no)
.left_join(batch)
.on(batch.name == table.batch_no)
.select(table.name, table.serial_no, table.batch_no, table.qty)
.where(table.parent == bundle)
.orderby(table.idx)
@@ -47,9 +52,9 @@ def get_bundle_entries(bundle: str, start: int = 0, page_length: int = 50, searc
if search:
search_term = f"%{search}%"
query = query.where((table.serial_no.like(search_term)) | (table.batch_no.like(search_term)))
query = query.where(serial.serial_no.like(search_term) | batch.batch_id.like(search_term))
entries = query.run(as_dict=True)
entries = add_number_labels(query.run(as_dict=True))
summary = get_bundle_summary(bundle)
summary["entries"] = entries
@@ -82,13 +87,13 @@ def download_bundle_entries_csv(bundle: str):
item = frappe.get_cached_value("Item", doc.item_code, ["has_serial_no", "has_batch_no"], as_dict=True)
rows = [get_csv_columns(item)]
for entry in doc.entries:
for entry in add_number_labels(doc.entries):
if item.has_serial_no and item.has_batch_no:
rows.append([entry.serial_no, entry.batch_no, abs(entry.qty)])
rows.append([entry.serial_number, entry.batch_number, abs(entry.qty)])
elif item.has_batch_no:
rows.append([entry.batch_no, abs(entry.qty)])
rows.append([entry.batch_number, abs(entry.qty)])
else:
rows.append([entry.serial_no])
rows.append([entry.serial_number])
build_csv_response(rows, f"{bundle}-entries")
@@ -132,9 +137,9 @@ def upsert_bundle_entries(
frappe.throw(_("Please add at least one Serial No or Batch to save"))
frappe.has_permission(doc.get("doctype"), "write", throw=True)
if get_type_of_transaction(doc, child_row) == "Inward":
make_serial_nos(child_row.item_code, entries)
make_batch_nos(child_row.item_code, entries)
resolve_number_entries(
child_row.item_code, entries, create=get_type_of_transaction(doc, child_row) == "Inward"
)
bundle = create_serial_batch_no_ledgers(entries, child_row, doc)
@@ -175,6 +180,10 @@ def apply_incremental_changes(bundle_name, child_row, entries, deleted, replace=
)
)
if child_row.item_code != bundle.item_code:
frappe.throw(_("The bundle belongs to a different item"))
resolve_number_entries(bundle.item_code, entries, create=bundle.type_of_transaction == "Inward")
sign = 1 if bundle.type_of_transaction == "Inward" else -1
if replace:
@@ -198,11 +207,6 @@ def apply_incremental_changes(bundle_name, child_row, entries, deleted, replace=
if row.get("serial_no"):
entry.serial_no = row.get("serial_no")
if entries and bundle.type_of_transaction == "Inward":
incoming = [frappe._dict(row) for row in entries]
make_serial_nos(child_row.item_code, incoming)
make_batch_nos(child_row.item_code, incoming)
for row in new_rows:
bundle.append(
"entries",

View File

@@ -34,6 +34,7 @@ from erpnext.stock.serial_batch_bundle import (
get_batches_from_bundle,
)
from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle
from erpnext.stock.serial_batch_identity import SerialBatchIdentity, resolve_number_entries
from erpnext.stock.valuation import FIFOValuation
@@ -1986,19 +1987,19 @@ def parse_csv_file_to_get_serial_batch(reader):
continue
if has_serial_no or (has_serial_no and has_batch_no):
_dict = {"serial_no": row[0].strip(), "qty": 1}
_dict = {"serial_number": row[0].strip(), "qty": 1}
if has_batch_no:
_dict.update(
{
"batch_no": row[1].strip(),
"batch_number": row[1].strip(),
"qty": row[2],
}
)
batch_nos.append(
{
"batch_no": row[1].strip(),
"batch_number": row[1].strip(),
"qty": row[2],
}
)
@@ -2007,7 +2008,7 @@ def parse_csv_file_to_get_serial_batch(reader):
elif has_batch_no:
batch_nos.append(
{
"batch_no": row[0].strip(),
"batch_number": row[0].strip(),
"qty": row[1],
}
)
@@ -2023,7 +2024,7 @@ def get_serial_batch_from_data(item_code, kwargs):
for serial_no in data:
if not serial_no:
continue
serial_nos.append({"serial_no": serial_no, "qty": 1})
serial_nos.append({"serial_number": serial_no, "qty": 1})
make_serial_nos(item_code, serial_nos)
@@ -2047,106 +2048,12 @@ def create_serial_nos(item_code: str, serial_nos: list | str):
def make_serial_nos(item_code, serial_nos):
item = frappe.get_cached_value(
"Item", item_code, ["description", "item_code", "item_name", "warranty_period"], as_dict=1
)
serial_nos = [d.get("serial_no").strip() for d in serial_nos if d.get("serial_no")]
existing_serial_nos = frappe.get_all("Serial No", filters={"name": ("in", serial_nos)})
existing_serial_nos = [d.get("name") for d in existing_serial_nos if d.get("name")]
serial_nos = list(set(serial_nos) - set(existing_serial_nos))
if not serial_nos:
return
serial_nos_details = []
user = frappe.session.user
for serial_no in serial_nos:
serial_nos_details.append(
(
serial_no,
serial_no,
now(),
now(),
user,
user,
item.item_code,
item.item_name,
item.description,
item.warranty_period or 0,
"Inactive",
)
)
fields = [
"name",
"serial_no",
"creation",
"modified",
"owner",
"modified_by",
"item_code",
"item_name",
"description",
"warranty_period",
"status",
]
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
frappe.msgprint(_("Serial Nos are created successfully"), alert=True)
"""Resolve explicit physical numbers and populate the entry links."""
resolve_number_entries(item_code, serial_nos, create=True)
def make_batch_nos(item_code, batch_nos):
item = frappe.get_cached_value("Item", item_code, ["description", "item_code"], as_dict=1)
batch_nos = [d.get("batch_no") for d in batch_nos if d.get("batch_no")]
existing_batches = frappe.get_all("Batch", filters={"name": ("in", batch_nos)})
existing_batches = [d.get("name") for d in existing_batches if d.get("name")]
batch_nos = list(set(batch_nos) - set(existing_batches))
if not batch_nos:
return
batch_nos_details = []
user = frappe.session.user
for batch_no in batch_nos:
if frappe.db.exists("Batch", batch_no):
continue
batch_nos_details.append(
(
batch_no,
batch_no,
now(),
now(),
user,
user,
item.item_code,
item.item_name,
item.description,
1,
)
)
fields = [
"name",
"batch_id",
"creation",
"modified",
"owner",
"modified_by",
"item",
"item_name",
"description",
"use_batchwise_valuation",
]
frappe.db.bulk_insert("Batch", fields=fields, values=set(batch_nos_details))
frappe.msgprint(_("Batch Nos are created successfully"), alert=True)
resolve_number_entries(item_code, batch_nos, create=True)
@frappe.whitelist()
@@ -2472,11 +2379,15 @@ def get_serial_and_batch_ledger(**kwargs):
@frappe.whitelist()
def get_auto_data(**kwargs):
from erpnext.stock.serial_batch_identity import add_number_labels
kwargs = frappe._dict(kwargs)
data = []
if cint(kwargs.has_serial_no):
return get_serial_nos_from_sre(kwargs) if kwargs.scio_detail else get_available_serial_nos(kwargs)
data = get_serial_nos_from_sre(kwargs) if kwargs.scio_detail else get_available_serial_nos(kwargs)
elif cint(kwargs.has_batch_no):
return get_batch_nos_from_sre(kwargs) if kwargs.scio_detail else get_auto_batch_nos(kwargs)
data = get_batch_nos_from_sre(kwargs) if kwargs.scio_detail else get_auto_batch_nos(kwargs)
return add_number_labels(data or [])
def get_available_batches_qty(available_batches):
@@ -3693,35 +3604,26 @@ def get_batch_no_from_serial_no(serial_no: str):
return frappe.get_cached_value("Serial No", serial_no, "batch_no")
@frappe.whitelist()
@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
):
if serial_no and not frappe.db.exists("Serial No", serial_no):
if type_of_transaction != "Inward":
frappe.throw(_("Serial No {0} does not exist").format(serial_no))
from erpnext.stock.serial_batch_identity import resolve_serial_batch_numbers
make_serial_no(serial_no, item_code)
if batch_no and not frappe.db.exists("Batch", batch_no):
if type_of_transaction != "Inward":
frappe.throw(_("Batch No {0} does not exist").format(batch_no))
make_batch_no(batch_no, item_code)
return resolve_serial_batch_numbers(
item_code,
serial_numbers=[serial_no] if serial_no else [],
batch_numbers=[batch_no] if batch_no else [],
create=type_of_transaction == "Inward",
)
def make_serial_no(serial_no, item_code):
serial_no_doc = frappe.new_doc("Serial No")
serial_no_doc.serial_no = serial_no
serial_no_doc.item_code = item_code
serial_no_doc.save(ignore_permissions=True)
return SerialBatchIdentity("Serial No").resolve(item_code, [serial_no], create=True)[0]
def make_batch_no(batch_no, item_code):
batch_doc = frappe.new_doc("Batch")
batch_doc.batch_id = batch_no
batch_doc.item = item_code
batch_doc.save(ignore_permissions=True)
return SerialBatchIdentity("Batch").resolve(item_code, [batch_no], create=True)[0]
@frappe.whitelist()

View File

@@ -39,26 +39,26 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
pr = self.make_draft_pr(item)
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
self.assertTrue(frappe.db.exists("Serial and Batch Bundle", summary.bundle))
self.assertEqual(summary.total_count, 2)
self.assertEqual(summary.total_qty, 2)
for serial_no in serials:
self.assertTrue(frappe.db.exists("Serial No", serial_no))
self.assertTrue(frappe.db.exists("Serial No", {"item_code": item, "serial_no": serial_no}))
def test_incremental_append_preserves_existing_entries(self):
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
pr = self.make_draft_pr(item, qty=3)
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(3)]
summary = self.upsert(pr, entries=[{"serial_no": serials[0]}, {"serial_no": serials[1]}])
summary = self.upsert(pr, entries=[{"serial_number": serials[0]}, {"serial_number": serials[1]}])
pr.items[0].serial_and_batch_bundle = summary.bundle
first_entry_names = set(
frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")
)
summary = self.upsert(pr, entries=[{"serial_no": serials[2]}])
summary = self.upsert(pr, entries=[{"serial_number": serials[2]}])
second_entry_names = set(
frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")
)
@@ -71,17 +71,24 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
pr = self.make_draft_pr(item)
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
pr.items[0].serial_and_batch_bundle = summary.bundle
to_delete = frappe.get_all(
"Serial and Batch Entry", {"parent": summary.bundle, "serial_no": serials[0]}, pluck="name"
"Serial and Batch Entry",
{
"parent": summary.bundle,
"serial_no": frappe.db.get_value("Serial No", {"item_code": item, "serial_no": serials[0]}),
},
pluck="name",
)
summary = self.upsert(pr, deleted=to_delete)
self.assertEqual(summary.total_count, 1)
remaining = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="serial_no")
self.assertEqual(remaining, [serials[1]])
self.assertEqual(
remaining, [frappe.db.get_value("Serial No", {"item_code": item, "serial_no": serials[1]})]
)
def test_batch_qty_update(self):
item = make_item(
@@ -111,14 +118,21 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
old_serial = f"SN-{frappe.generate_hash(length=8)}"
new_serial = f"SN-{frappe.generate_hash(length=8)}"
summary = self.upsert(pr, entries=[{"serial_no": old_serial}])
summary = self.upsert(pr, entries=[{"serial_number": old_serial}])
pr.items[0].serial_and_batch_bundle = summary.bundle
entry_name = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")[0]
self.upsert(pr, entries=[{"name": entry_name, "serial_no": new_serial}])
self.upsert(pr, entries=[{"name": entry_name, "serial_number": new_serial}])
self.assertEqual(frappe.db.get_value("Serial and Batch Entry", entry_name, "serial_no"), new_serial)
self.assertTrue(frappe.db.exists("Serial No", new_serial))
self.assertEqual(
frappe.db.get_value(
"Serial No",
frappe.db.get_value("Serial and Batch Entry", entry_name, "serial_no"),
"serial_no",
),
new_serial,
)
self.assertTrue(frappe.db.exists("Serial No", {"item_code": item, "serial_no": new_serial}))
def test_auto_create_missing_batch_no(self):
item = make_item(properties={"is_stock_item": 1, "has_batch_no": 1}).name
@@ -126,14 +140,14 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
batch1 = f"BNEW-{frappe.generate_hash(length=8)}"
batch2 = f"BNEW-{frappe.generate_hash(length=8)}"
self.assertFalse(frappe.db.exists("Batch", batch1))
summary = self.upsert(pr, entries=[{"batch_no": batch1, "qty": 4}])
self.assertTrue(frappe.db.exists("Batch", batch1))
self.assertFalse(frappe.db.exists("Batch", {"item": item, "batch_id": batch1}))
summary = self.upsert(pr, entries=[{"batch_number": batch1, "qty": 4}])
self.assertTrue(frappe.db.exists("Batch", {"item": item, "batch_id": batch1}))
pr.items[0].serial_and_batch_bundle = summary.bundle
summary = self.upsert(pr, entries=[{"batch_no": batch2, "qty": 1}])
summary = self.upsert(pr, entries=[{"batch_number": batch2, "qty": 1}])
self.assertTrue(frappe.db.exists("Batch", batch2))
self.assertTrue(frappe.db.exists("Batch", {"item": item, "batch_id": batch2}))
self.assertEqual(summary.total_qty, 5)
def test_update_batch_no_of_existing_entry(self):
@@ -164,7 +178,7 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
pr = self.make_draft_pr(item)
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
bundle = summary.bundle
pr.items[0].serial_and_batch_bundle = bundle
pr.items[0].db_set("serial_and_batch_bundle", bundle)
@@ -184,12 +198,12 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
pr = self.make_draft_pr(item)
victim_pr = self.make_draft_pr(item)
summary = self.upsert(pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}])
summary = self.upsert(pr, entries=[{"serial_number": f"SN-{frappe.generate_hash(length=8)}"}])
bundle = summary.bundle
pr.items[0].db_set("serial_and_batch_bundle", bundle)
victim_summary = self.upsert(
victim_pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}]
victim_pr, entries=[{"serial_number": f"SN-{frappe.generate_hash(length=8)}"}]
)
victim_bundle = victim_summary.bundle
victim_pr.items[0].db_set("serial_and_batch_bundle", victim_bundle)
@@ -216,7 +230,7 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
pr = self.make_draft_pr(item, qty=5)
serials = sorted(f"SN-{frappe.generate_hash(length=8)}" for _ in range(5))
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
page = get_bundle_entries(summary.bundle, start=0, page_length=2)
self.assertEqual(len(page["entries"]), 2)
@@ -231,22 +245,22 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
token = frappe.generate_hash(length=8)
serials = [f"AAA-{token}", f"BBB-{token}"]
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
page = get_bundle_entries(summary.bundle, search=f"AAA-{token}")
self.assertEqual(len(page["entries"]), 1)
self.assertEqual(page["entries"][0].serial_no, f"AAA-{token}")
self.assertEqual(page["entries"][0].serial_number, f"AAA-{token}")
def test_rejected_bundle_created_separately(self):
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
pr = self.make_draft_pr(item)
pr.items[0].rejected_warehouse = "_Test Warehouse 1 - _TC"
accepted = self.upsert(pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}])
accepted = self.upsert(pr, entries=[{"serial_number": f"SN-{frappe.generate_hash(length=8)}"}])
pr.items[0].serial_and_batch_bundle = accepted.bundle
rejected = self.upsert(
pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}], is_rejected=1
pr, entries=[{"serial_number": f"SN-{frappe.generate_hash(length=8)}"}], is_rejected=1
)
self.assertNotEqual(accepted.bundle, rejected.bundle)
@@ -260,21 +274,24 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
old_serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
new_serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(3)]
summary = self.upsert(pr, entries=[{"serial_no": d} for d in old_serials])
summary = self.upsert(pr, entries=[{"serial_number": d} for d in old_serials])
pr.items[0].serial_and_batch_bundle = summary.bundle
summary = self.upsert(pr, entries=[{"serial_no": d} for d in new_serials], replace=1)
summary = self.upsert(pr, entries=[{"serial_number": d} for d in new_serials], replace=1)
self.assertEqual(summary.total_count, 3)
remaining = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="serial_no")
self.assertEqual(sorted(remaining), sorted(new_serials))
self.assertEqual(
sorted(frappe.get_all("Serial No", filters={"name": ("in", remaining)}, pluck="serial_no")),
sorted(new_serials),
)
def test_replace_with_no_entries_removes_bundle(self):
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
pr = self.make_draft_pr(item)
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
bundle = summary.bundle
pr.items[0].serial_and_batch_bundle = bundle
@@ -295,7 +312,7 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
summary = upsert_bundle_entries(
child_row=json.dumps(child_row, default=str),
doc=json.dumps(se.as_dict(), default=str),
entries=json.dumps([{"serial_no": f"SN-{frappe.generate_hash(length=8)}"} for _ in range(2)]),
entries=json.dumps([{"serial_number": f"SN-{frappe.generate_hash(length=8)}"} for _ in range(2)]),
deleted=json.dumps([]),
)
@@ -323,7 +340,7 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
upsert_bundle_entries,
child_row=json.dumps(child_row, default=str),
doc=json.dumps(pr.as_dict(), default=str),
entries=json.dumps([{"serial_no": "SBIE-PT-0001"}]),
entries=json.dumps([{"serial_number": "SBIE-PT-0001"}]),
)
def test_upsert_rejects_unsupported_voucher_type(self):
@@ -342,5 +359,5 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
upsert_bundle_entries,
child_row=json.dumps(child_row, default=str),
doc=json.dumps(doc, default=str),
entries=json.dumps([{"serial_no": "SBIE-PT-0002"}]),
entries=json.dumps([{"serial_number": "SBIE-PT-0002"}]),
)

View File

@@ -4,6 +4,8 @@
import json
import frappe
# Explicit names below model historical records referenced by legacy ledgers.
from frappe.utils import add_days, add_to_date, flt, nowtime, today
from erpnext.stock.doctype.item.test_item import make_item
@@ -46,7 +48,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
"item_code": serial_item_code,
"company": "_Test Company",
}
).insert(ignore_permissions=True)
).insert(ignore_permissions=True, set_name=sn)
bundle_doc = make_serial_batch_bundle(
{
@@ -252,7 +254,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
"item": batch_item_code,
"use_batchwise_valuation": 0,
}
).insert(ignore_permissions=True)
).insert(ignore_permissions=True, set_name=batch_id)
self.assertTrue(batch_doc.use_batchwise_valuation)
batch_doc.db_set(
@@ -422,7 +424,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
"item": batch_item_code,
"use_batchwise_valuation": 0,
}
).insert(ignore_permissions=True)
).insert(ignore_permissions=True, set_name=batch_id)
self.assertTrue(batch_doc.use_batchwise_valuation)
batch_doc.db_set(
@@ -550,7 +552,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
"item_code": serial_no_item_code,
"company": "_Test Company",
}
).insert(ignore_permissions=True)
).insert(ignore_permissions=True, set_name=serial_no_id)
sn_doc.db_set(
{
@@ -685,7 +687,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
"item_code": serial_and_batch_code,
"company": "_Test Company",
}
).insert(ignore_permissions=True)
).insert(ignore_permissions=True, set_name=serial_no)
bundle_doc = make_serial_batch_bundle(
{
@@ -741,7 +743,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
"item_code": item,
"company": "_Test Company",
}
).insert(ignore_permissions=True)
).insert(ignore_permissions=True, set_name=serial_no)
item_row = pr.items[0]
item_row.type_of_transaction = "Inward"
@@ -840,35 +842,37 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
item_code = make_item(properties={"has_batch_no": 1}).name
batch_id = "TEST-BATTCCH-VAL-00001"
batch_nos = [{"batch_no": batch_id, "qty": 1}]
batch_nos = [{"batch_number": batch_id, "qty": 1}]
make_batch_nos(item_code, batch_nos)
self.assertTrue(frappe.db.exists("Batch", batch_id))
use_batchwise_valuation = frappe.db.get_value("Batch", batch_id, "use_batchwise_valuation")
self.assertTrue(frappe.db.exists("Batch", {"item": item_code, "batch_id": batch_id}))
use_batchwise_valuation = frappe.db.get_value(
"Batch", {"item": item_code, "batch_id": batch_id}, "use_batchwise_valuation"
)
self.assertEqual(use_batchwise_valuation, 1)
batch_id = "TEST-BATTCCH-VAL-00001"
batch_nos = [{"batch_no": batch_id, "qty": 1}]
batch_nos = [{"batch_number": batch_id, "qty": 1}]
# Shouldn't throw duplicate entry error
make_batch_nos(item_code, batch_nos)
self.assertTrue(frappe.db.exists("Batch", batch_id))
self.assertTrue(frappe.db.exists("Batch", {"item": item_code, "batch_id": batch_id}))
def test_serial_no_duplicate_entry(self):
item_code = make_item(properties={"has_serial_no": 1}).name
serial_no_id = "TEST-SNID-VAL-00001"
serial_nos = [{"serial_no": serial_no_id, "qty": 1}]
serial_nos = [{"serial_number": serial_no_id, "qty": 1}]
make_serial_nos(item_code, serial_nos)
self.assertTrue(frappe.db.exists("Serial No", serial_no_id))
self.assertTrue(frappe.db.exists("Serial No", {"item_code": item_code, "serial_no": serial_no_id}))
serial_no_id = "TEST-SNID-VAL-00001"
serial_nos = [{"batch_no": serial_no_id, "qty": 1}]
serial_nos = [{"serial_number": serial_no_id, "qty": 1}]
# Shouldn't throw duplicate entry error
make_serial_nos(item_code, serial_nos)
self.assertTrue(frappe.db.exists("Serial No", serial_no_id))
self.assertTrue(frappe.db.exists("Serial No", {"item_code": item_code, "serial_no": serial_no_id}))
@ERPNextTestSuite.change_settings(
"Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1}
@@ -879,10 +883,10 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
item_code = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
serial_no = f"{item_code}-001"
serial_nos = [{"serial_no": serial_no, "qty": 1}]
serial_nos = [{"serial_number": serial_no, "qty": 1}]
make_serial_nos(item_code, serial_nos)
pr1 = make_purchase_receipt(item=item_code, qty=1, rate=500, serial_no=[serial_no])
pr1 = make_purchase_receipt(item=item_code, qty=1, rate=500, serial_no=[serial_nos[0]["serial_no"]])
pr2 = make_purchase_receipt(item=item_code, qty=1, rate=500, do_not_save=True)
pr1.reload()
@@ -906,7 +910,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
"item_code": sn_item,
"company": "_Test Company",
}
).insert(ignore_permissions=True)
).insert(ignore_permissions=True, set_name=serial_no)
serial_nos.append(serial_no)
frappe.flags.ignore_serial_batch_bundle_validation = True
@@ -1069,7 +1073,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
"item": item_code,
"company": "_Test Company",
}
).insert(ignore_permissions=True)
).insert(ignore_permissions=True, set_name="ACSBBO-TACSB-00001")
make_stock_entry(
item_code=item_code,
@@ -1130,7 +1134,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
"item": item_code,
"company": "_Test Company",
}
).insert(ignore_permissions=True)
).insert(ignore_permissions=True, set_name="TST-ACSBBO-TACSB-00001")
bundle_doc = make_serial_batch_bundle(
{
@@ -1233,7 +1237,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
"item": batch_item_code,
"use_batchwise_valuation": 0,
}
).insert(ignore_permissions=True)
).insert(ignore_permissions=True, set_name=batch_id)
batch_doc.db_set(
{
@@ -1322,7 +1326,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
"item": batch_item_code,
"use_batchwise_valuation": 0,
}
).insert(ignore_permissions=True)
).insert(ignore_permissions=True, set_name=batch_id)
batch_doc.db_set(
{
@@ -1391,7 +1395,9 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
make_item(item_code, props)
if batch_no and not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert()
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
set_name=batch_no
)
pr = make_purchase_receipt(
item_code=item_code, qty=10, rate=100, batch_no=batch_no, use_serial_batch_fields=True
@@ -1475,7 +1481,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc(
{"doctype": "Batch", "batch_id": batch_no, "item": item_code, "company": "_Test Company"}
).insert(ignore_permissions=True)
).insert(ignore_permissions=True, set_name=batch_no)
def _allow_negative_stock_temporarily(self):
for field in ("allow_negative_stock", "allow_negative_stock_for_batch"):

View File

@@ -1,7 +1,7 @@
{
"actions": [],
"allow_import": 1,
"autoname": "field:serial_no",
"autoname": "hash",
"creation": "2013-05-16 10:59:15",
"description": "Distinct unit of an Item",
"doctype": "DocType",
@@ -63,7 +63,8 @@
"oldfieldname": "serial_no",
"oldfieldtype": "Data",
"reqd": 1,
"unique": 1
"in_list_view": 1,
"search_index": 1
},
{
"fieldname": "item_code",
@@ -316,7 +317,7 @@
"modified_by": "Administrator",
"module": "Stock",
"name": "Serial No",
"naming_rule": "By fieldname",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
@@ -367,10 +368,12 @@
}
],
"row_format": "Dynamic",
"search_fields": "item_code",
"search_fields": "serial_no,item_code",
"show_name_in_global_search": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
"track_changes": 1,
"title_field": "serial_no",
"show_title_field_in_link": 1
}

View File

@@ -11,6 +11,7 @@ from frappe.query_builder.functions import Coalesce
from frappe.utils import cint, cstr, getdate, nowdate, safe_json_loads
from erpnext.controllers.stock_controller import StockController
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
class SerialNoCannotCreateDirectError(ValidationError):
@@ -65,6 +66,7 @@ class SerialNo(StockController):
self.via_stock_ledger = False
def validate(self):
SerialBatchIdentity("Serial No").validate(self)
if self.get("__islocal") and self.warehouse and not self.via_stock_ledger:
frappe.throw(
_(
@@ -110,7 +112,7 @@ class SerialNo(StockController):
# Find the exact match
sle_exists = False
for d in sl_entries:
if self.name.upper() in get_serial_nos(d.serial_no):
if self.name in get_serial_nos(d.serial_no):
sle_exists = True
break
@@ -120,23 +122,27 @@ class SerialNo(StockController):
)
def get_available_serial_nos(serial_no_series, qty) -> list[str]:
def get_available_serial_nos(serial_no_series, qty, item_code=None) -> list[str]:
serial_nos = []
for _i in range(cint(qty)):
serial_nos.append(get_new_serial_number(serial_no_series))
serial_nos.append(get_new_serial_number(serial_no_series, item_code))
return serial_nos
def get_new_serial_number(series):
def get_new_serial_number(series, item_code=None):
sr_no = make_autoname(series, "Serial No")
if frappe.db.exists("Serial No", sr_no):
sr_no = get_new_serial_number(series)
if frappe.db.exists("Serial No", {"serial_no": sr_no, **({"item_code": item_code} if item_code else {})}):
sr_no = get_new_serial_number(series, item_code)
return sr_no
def get_items_html(serial_nos, item_code):
body = ", ".join(serial_nos)
from frappe.utils import escape_html
labels = SerialBatchIdentity("Serial No").labels(serial_nos)
body = ", ".join(escape_html(labels.get(name, name)) for name in serial_nos)
item_code = escape_html(item_code)
return f"""<details><summary>
<b>{item_code}:</b> {len(serial_nos)} Serial Numbers <span class="caret"></span>
</summary>
@@ -306,4 +312,5 @@ def get_serial_nos_for_outward(kwargs):
def on_doctype_update():
SerialBatchIdentity("Serial No").sync_constraint()
frappe.db.add_index("Serial No", ["item_code", "warehouse"])

View File

@@ -6,6 +6,8 @@
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
@@ -202,7 +204,7 @@ class TestSerialNo(ERPNextTestSuite):
"serial_no": serial_no,
"company": "_Test Company",
}
).insert()
).insert(set_name=serial_no)
make_stock_entry(
item_code=item_code, to_warehouse=warehouse, qty=1, rate=42, serial_no=[serial_nos[0]]
@@ -350,7 +352,7 @@ class TestSerialNo(ERPNextTestSuite):
"company": "_Test Company",
"warranty_expiry_date": past_date,
}
).insert()
).insert(set_name="_TCWARREXP" + random_string(6))
frappe.db.set_value("Serial No", expired_sr.name, "maintenance_status", "Under Warranty")
self.assertEqual(
frappe.db.get_value("Serial No", expired_sr.name, "maintenance_status"), "Under Warranty"
@@ -365,7 +367,7 @@ class TestSerialNo(ERPNextTestSuite):
"company": "_Test Company",
"warranty_expiry_date": future_date,
}
).insert()
).insert(set_name="_TCWARRACT" + random_string(6))
self.assertEqual(
frappe.db.get_value("Serial No", active_sr.name, "maintenance_status"), "Under Warranty"
)
@@ -402,7 +404,7 @@ class TestSerialNo(ERPNextTestSuite):
"amc_expiry_date": past_date,
"warranty_expiry_date": future_date,
}
).insert()
).insert(set_name="_TCAMCEXCL" + random_string(6))
frappe.db.set_value("Serial No", excluded_sr.name, "maintenance_status", "Out of AMC")
# Negative control: same lapsed amc date, but a status NOT in the excluded list, so it
@@ -416,7 +418,7 @@ class TestSerialNo(ERPNextTestSuite):
"company": "_Test Company",
"amc_expiry_date": past_date,
}
).insert()
).insert(set_name="_TCAMCCAND" + random_string(6))
frappe.db.set_value("Serial No", candidate_sr.name, "maintenance_status", "Under AMC")
update_maintenance_status()
@@ -446,7 +448,7 @@ class TestSerialNo(ERPNextTestSuite):
"company": "_Test Company",
"amc_expiry_date": past_date,
}
).insert()
).insert(set_name="_TCAMCNULL" + random_string(6))
# Force a NULL maintenance_status while a lapsed amc date keeps the row in or_filters.
frappe.db.set_value("Serial No", null_sr.name, "maintenance_status", None)
self.assertIsNone(frappe.db.get_value("Serial No", null_sr.name, "maintenance_status"))

View File

@@ -910,7 +910,7 @@ class TestStockEntry(ERPNextTestSuite):
doc.serial_no = serial_no
doc.item_code = "_Test Serialized Item"
doc.company = "_Test Company"
doc.insert(ignore_permissions=True)
doc.insert(ignore_permissions=True, set_name=serial_no)
se = frappe.copy_doc(self.globalTestRecords["Stock Entry"][0])
se.get("items")[0].item_code = "_Test Serialized Item"
@@ -2831,6 +2831,9 @@ class TestStockEntry(ERPNextTestSuite):
"Test Use Serial and Batch Item SN Item - SN 001",
"Test Use Serial and Batch Item SN Item - SN 002",
]
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
serial_nos = SerialBatchIdentity("Serial No").resolve(item.name, serial_nos, create=True)
se = make_stock_entry(
item_code=item.name,

View File

@@ -615,7 +615,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
"serial_no": "SR-CREATED-SR-NO",
"company": "_Test Company",
}
).insert()
).insert(set_name="SR-CREATED-SR-NO")
sr = create_stock_reconciliation(
item_code=item.name,
@@ -1351,7 +1351,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
"item": batch_item_code,
"use_batchwise_valuation": 0,
}
).insert(ignore_permissions=True)
).insert(set_name=batch_id, ignore_permissions=True)
self.assertTrue(batch_doc.use_batchwise_valuation)

View File

@@ -7,7 +7,10 @@ from frappe import _
from frappe.query_builder.functions import Sum
from frappe.utils import flt, get_datetime, today
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
@with_serial_batch_numbers
def execute(filters=None):
columns, data = [], []
data = get_data(filters)

View File

@@ -11,9 +11,11 @@ from erpnext.stock.report.stock_ledger.stock_ledger import (
get_opening_balance,
get_stock_ledger_entries,
)
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
from erpnext.stock.utils import is_reposting_item_valuation_in_progress
@with_serial_batch_numbers
def execute(filters=None):
is_reposting_item_valuation_in_progress()
columns = get_columns(filters)

View File

@@ -6,7 +6,10 @@ import frappe
from frappe import _
from frappe.query_builder.functions import Date
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
@with_serial_batch_numbers
def execute(filters=None):
validate_filters(filters)

View File

@@ -46,12 +46,13 @@ class TestBatchItemExpiryStatus(ERPNextTestSuite):
data = self.run_report(item=item)
# Columns: [item, item_name, batch, stock_uom, quantity, expires_on, expiry_in_days]
row = next((r for r in data if r[2] == batch_no), None)
# Physical batch numbers are displayed; the final hidden column retains the ID.
row = next((r for r in data if r[-1] == batch_no), None)
self.assertIsNotNone(row, f"Batch {batch_no} not found in report for item {item}")
self.assertEqual(row[0], item)
self.assertEqual(row[2], batch_no)
self.assertEqual(row[2], frappe.db.get_value("Batch", batch_no, "batch_id"))
self.assertEqual(row[-1], batch_no)
self.assertEqual(row[4], 10)
# expiry = batch manufacturing_date + 30 day shelf life; matches the Batch record
batch_expiry = frappe.db.get_value("Batch", batch_no, "expiry_date")

View File

@@ -3,7 +3,10 @@ from collections import defaultdict
import frappe
from frappe import _
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
@with_serial_batch_numbers
def execute(filters=None):
filters = frappe._dict(filters or {})
return get_columns(), get_data(filters)

View File

@@ -11,10 +11,12 @@ from erpnext.accounts.report.utils import validate_mandatory_date_range
from erpnext.deprecation_dumpster import deprecated
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import StockClosing
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
SLE_COUNT_LIMIT = 100_000
@with_serial_batch_numbers
def execute(filters=None):
if not filters:
filters = {}

View File

@@ -8,6 +8,8 @@ from frappe import _
from frappe.utils import flt
from frappe.utils.nestedset import get_descendants_of
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
SLE_FIELDS = (
"name",
"item_code",
@@ -26,6 +28,7 @@ SLE_FIELDS = (
)
@with_serial_batch_numbers
def execute(filters=None):
columns = get_columns()
data = get_data(filters)

View File

@@ -7,8 +7,10 @@ import frappe
from frappe import _
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
@with_serial_batch_numbers
def execute(filters=None):
columns, data = [], []
columns = get_columns()

View File

@@ -6,8 +6,10 @@ from frappe import _
from frappe.utils import add_to_date, flt, today
from erpnext.stock.report.stock_ledger.stock_ledger import execute as stock_ledger_execute
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
@with_serial_batch_numbers
def execute(filters: dict | None = None):
"""Return columns and data for the report.

View File

@@ -6,7 +6,11 @@ from typing import Any
import frappe
from frappe import _
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
@with_serial_batch_numbers
def execute(filters=None):
data = get_data(filters)
columns = get_columns(filters, data)
@@ -199,57 +203,35 @@ def get_voucher_type(doctype: Any, txt: str, searchfield: Any, start: int, page_
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_serial_nos(doctype: Any, txt: str, searchfield: Any, start: int, page_len: int, filters: dict):
query_filters = {}
if txt:
query_filters["serial_no"] = ["like", f"%{txt}%"]
if filters.get("voucher_no"):
serial_batch_bundle = frappe.get_cached_value(
"Serial and Batch Bundle",
{"voucher_no": ("in", filters.get("voucher_no")), "docstatus": 1, "is_cancelled": 0},
"name",
)
query_filters["parent"] = serial_batch_bundle
if not txt:
query_filters["serial_no"] = ("is", "set")
return frappe.get_all(
"Serial and Batch Entry", filters=query_filters, fields=["serial_no"], as_list=True
)
else:
query_filters["item_code"] = filters.get("item_code")
return frappe.get_all("Serial No", filters=query_filters, as_list=True)
return get_number_options("Serial No", txt, start, page_len, filters)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_batch_nos(doctype: Any, txt: str, searchfield: Any, start: int, page_len: int, filters: dict):
return get_number_options("Batch", txt, start, page_len, filters)
def get_number_options(doctype, txt, start, page_len, filters):
identity = SerialBatchIdentity(doctype)
query_filters = {}
if filters.get("voucher_no") and txt:
query_filters["batch_no"] = ["like", f"%{txt}%"]
if filters.get("item_code"):
query_filters[identity.item_field] = filters["item_code"]
if filters.get("voucher_no"):
serial_batch_bundle = frappe.get_cached_value(
bundles = frappe.get_all(
"Serial and Batch Bundle",
{"voucher_no": ("in", filters.get("voucher_no")), "docstatus": 1, "is_cancelled": 0},
"name",
filters={"voucher_no": ("in", filters["voucher_no"]), "docstatus": 1, "is_cancelled": 0},
pluck="name",
)
query_filters["parent"] = serial_batch_bundle
if not txt:
query_filters["batch_no"] = ("is", "set")
return frappe.get_all(
"Serial and Batch Entry", filters=query_filters, fields=["batch_no"], as_list=True
)
else:
if txt:
query_filters["name"] = ["like", f"%{txt}%"]
query_filters["item"] = filters.get("item_code")
return frappe.get_all("Batch", filters=query_filters, as_list=True)
link = "serial_no" if doctype == "Serial No" else "batch_no"
ids = frappe.get_all("Serial and Batch Entry", filters={"parent": ("in", bundles)}, pluck=link)
query_filters["name"] = ("in", [name for name in ids if name])
return frappe.get_list(
doctype,
filters=query_filters,
or_filters={identity.number_field: ("like", f"%{txt}%"), "name": txt},
fields=["name", identity.number_field],
start=start,
page_length=page_len,
as_list=True,
)

View File

@@ -65,7 +65,7 @@ frappe.query_reports["Serial No and Batch Traceability"] = {
},
};
function getTraceabilityLink({ type, value, original_value, item_code, data, filter_values }) {
function getTraceabilityLink({ type, value, original_value, number, item_code, data, filter_values }) {
if (!value) return value;
const link_doctype = type === "batch_no" ? "Batch" : "Serial No";
@@ -87,7 +87,7 @@ function getTraceabilityLink({ type, value, original_value, item_code, data, fil
return `<a class="${css_class}" href="${frappe.utils.get_form_link(
link_doctype,
original_value
)}">${frappe.utils.escape_html(original_value)}</a>`;
)}">${frappe.utils.escape_html(number)}</a>`;
}
function custom_formatter(value, row, column, data, default_formatter) {
@@ -100,11 +100,12 @@ function custom_formatter(value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (["batch_no", "serial_no"].includes(column.fieldname) && value) {
if (["batch_no_number", "serial_no_number"].includes(column.fieldname) && value) {
value = getTraceabilityLink({
type: column.fieldname,
type: column.reference_field,
value,
original_value,
original_value: data[column.reference_field],
number: original_value,
item_code,
data,
filter_values,

View File

@@ -5,7 +5,10 @@ import frappe
from frappe import _
from frappe.query_builder import Case
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
@with_serial_batch_numbers
def execute(filters: dict | None = None):
report = ReportData(filters)
report.validate_filters()

View File

@@ -8,12 +8,14 @@ from frappe import _
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_serial_nos_from_sle
from erpnext.stock.serial_batch_bundle import get_serial_no_status
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
from erpnext.stock.stock_ledger import get_stock_ledger_entries
BUYING_VOUCHER_TYPES = ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"]
SELLING_VOUCHER_TYPES = ["Sales Invoice", "Delivery Note"]
@with_serial_batch_numbers
def execute(filters=None):
columns = get_columns(filters)
data = get_data(filters)

View File

@@ -8,7 +8,7 @@
"filters": [],
"idx": 3,
"is_standard": "Yes",
"json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.modified\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [[\"Serial No\", \"warehouse\", \"=\", \"\"]], \"sort_order_next\": \"desc\", \"columns\": [[\"name\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"amc_expiry_date\", \"Serial No\"], [\"maintenance_status\", \"Serial No\"],[\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"]]}",
"json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.modified\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [[\"Serial No\", \"warehouse\", \"=\", \"\"]], \"sort_order_next\": \"desc\", \"columns\": [[\"serial_no\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"amc_expiry_date\", \"Serial No\"], [\"maintenance_status\", \"Serial No\"],[\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"]]}",
"letterhead": null,
"modified": "2024-09-26 13:07:23.451182",
"modified_by": "Administrator",

View File

@@ -8,7 +8,7 @@
"filters": [],
"idx": 4,
"is_standard": "Yes",
"json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.name\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [], \"sort_order_next\": \"desc\", \"columns\": [[\"name\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"warehouse\", \"Serial No\"], [\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"],[\"purchase_document_no\", \"Serial No\"]]}",
"json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.serial_no\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [], \"sort_order_next\": \"desc\", \"columns\": [[\"serial_no\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"warehouse\", \"Serial No\"], [\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"],[\"purchase_document_no\", \"Serial No\"]]}",
"letterhead": null,
"modified": "2024-09-26 13:10:52.693648",
"modified_by": "Administrator",

View File

@@ -8,7 +8,7 @@
"filters": [],
"idx": 3,
"is_standard": "Yes",
"json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.modified\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [[\"Serial No\", \"warehouse\", \"=\", \"\"]], \"sort_order_next\": \"desc\", \"columns\": [[\"name\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"amc_expiry_date\", \"Serial No\"], [\"maintenance_status\", \"Serial No\"],[\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"]]}",
"json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.modified\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [[\"Serial No\", \"warehouse\", \"=\", \"\"]], \"sort_order_next\": \"desc\", \"columns\": [[\"serial_no\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"amc_expiry_date\", \"Serial No\"], [\"maintenance_status\", \"Serial No\"],[\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"]]}",
"letterhead": null,
"modified": "2025-04-24 13:07:23.451182",
"modified_by": "Administrator",

View File

@@ -15,12 +15,14 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_in
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import get_stock_balance_for
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
from erpnext.stock.utils import (
is_reposting_item_valuation_in_progress,
update_included_uom_in_report,
)
@with_serial_batch_numbers
def execute(filters=None):
is_reposting_item_valuation_in_progress()
include_uom = filters.get("include_uom")

View File

@@ -7,6 +7,7 @@ import frappe
from frappe import _
from frappe.utils import cint, flt, get_link_to_form, parse_json
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
from erpnext.stock.utils import get_valuation_method
SLE_FIELDS = (
@@ -31,6 +32,7 @@ SLE_FIELDS = (
)
@with_serial_batch_numbers
def execute(filters=None):
columns = get_columns()
data = get_data(filters)

View File

@@ -10,8 +10,10 @@ from frappe.utils import cint, flt
from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import (
get_data as stock_ledger_invariant_check,
)
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
@with_serial_batch_numbers
def execute(filters=None):
columns, data = [], []

View File

@@ -7,8 +7,10 @@ import frappe
from frappe import _
from erpnext.stock.doctype.batch.batch import get_batch_qty
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
@with_serial_batch_numbers
def execute(filters=None):
if not filters:
filters = {}

View File

@@ -605,6 +605,10 @@ def get_serial_nos_from_bundle(serial_and_batch_bundle, serial_nos=None):
def get_serial_or_batch_nos(bundle):
from frappe.utils import escape_html
from erpnext.stock.serial_batch_identity import add_number_labels
# For print format
bundle_data = frappe.get_cached_value(
@@ -620,16 +624,18 @@ def get_serial_or_batch_nos(bundle):
data = frappe.get_all("Serial and Batch Entry", fields=fields, filters={"parent": bundle})
add_number_labels(data)
if bundle_data.has_serial_no and not bundle_data.has_batch_no:
return ", ".join([d.serial_no for d in data])
return ", ".join([escape_html(d.serial_number) for d in data])
elif bundle_data.has_batch_no:
html = "<table class= 'table table-borderless' style='margin-top: 0px;margin-bottom: 0px;'>"
for d in data:
if d.serial_no:
html += f"<tr><td>{d.batch_no}</td><td>{d.serial_no}</td><td>{abs(d.qty)}</td></tr>"
html += f"<tr><td>{escape_html(d.batch_number or '')}</td><td>{escape_html(d.serial_number or '')}</td><td>{abs(d.qty)}</td></tr>"
else:
html += f"<tr><td>{d.batch_no}</td><td>{abs(d.qty)}</td></tr>"
html += f"<tr><td>{escape_html(d.batch_number or '')}</td><td>{abs(d.qty)}</td></tr>"
html += "</table>"
@@ -1384,57 +1390,32 @@ class SerialBatchCreation:
self.batches = frappe._dict({self.batch_no: abs(self.actual_qty)})
def make_serial_no_if_not_exists(self):
non_exists_serial_nos = []
for row in self.serial_nos:
if not frappe.db.exists("Serial No", row):
non_exists_serial_nos.append(row)
if non_exists_serial_nos:
self.make_serial_nos(non_exists_serial_nos)
# Transaction fields contain IDs. Physical input is resolved by the input API.
existing = set(
frappe.get_all(
"Serial No",
filters={"name": ("in", self.serial_nos), "item_code": self.item_code},
pluck="name",
)
)
for name in self.serial_nos:
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):
serial_nos_details = []
batch_no = None
if self.batches:
batch_no = next(iter(self.batches.keys()))
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
for serial_no in serial_nos:
serial_nos_details.append(
(
serial_no,
serial_no,
now(),
now(),
frappe.session.user,
frappe.session.user,
self.warehouse,
self.company,
self.item_code,
self.item_name,
self.description,
"Active",
batch_no,
)
)
if serial_nos_details:
fields = [
"name",
"serial_no",
"creation",
"modified",
"owner",
"modified_by",
"warehouse",
"company",
"item_code",
"item_name",
"description",
"status",
"batch_no",
]
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
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")
@@ -1535,95 +1516,37 @@ class SerialBatchCreation:
)
def get_auto_created_serial_nos(self):
sr_nos = []
serial_nos_details = []
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
if not self.serial_no_series:
msg = f"Please set Serial No Series in the item {self.item_code} or create Serial and Batch Bundle manually."
frappe.throw(_(msg))
frappe.throw(_("Please set Serial No Series in Item {0}").format(self.item_code))
voucher_no = ""
if self.get("voucher_no"):
voucher_no = self.get("voucher_no")
voucher_type = ""
if self.get("voucher_type"):
voucher_type = self.get("voucher_type")
obj = NamingSeries(self.serial_no_series)
current_value = obj.get_current_value()
series = NamingSeries(self.serial_no_series)
current_value = series.get_current_value()
def get_series(partial_series, digits):
return f"{current_value:0{digits}d}"
posting_date = frappe.db.get_value(
voucher_type,
voucher_no,
"posting_date",
)
for _i in range(abs(cint(self.actual_qty))):
numbers = []
for _index in range(abs(cint(self.actual_qty))):
current_value += 1
serial_no = parse_naming_series(self.serial_no_series, number_generator=get_series)
numbers.append(parse_naming_series(self.serial_no_series, number_generator=get_series))
sr_nos.append(serial_no)
serial_nos_details.append(
(
serial_no,
serial_no,
now(),
now(),
frappe.session.user,
frappe.session.user,
self.warehouse,
self.company,
self.item_code,
self.item_name,
self.description,
"Active",
voucher_type,
voucher_no,
posting_date,
self.batch_no,
)
)
if serial_nos_details:
fields = [
"name",
"serial_no",
"creation",
"modified",
"owner",
"modified_by",
"warehouse",
"company",
"item_code",
"item_name",
"description",
"status",
"reference_doctype",
"reference_name",
"posting_date",
"batch_no",
]
try:
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
except Exception as e:
if e and len(e.args) > 1 and "Duplicate" in e.args[1]:
frappe.throw(
_(
"A naming series conflict occurred while creating serial numbers. Please change the naming series for the item {0}."
).format(bold(self.item_code)),
title=_("Duplicate Serial Number Error"),
)
else:
raise e
obj.update_counter(current_value)
return sr_nos
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"),
},
)
series.update_counter(current_value)
return [ids[number] for number in numbers]
def get_serial_or_batch_items(items):

View File

@@ -0,0 +1,81 @@
"""Display physical numbers while retaining document IDs for stock references."""
from functools import wraps
import frappe
from frappe import _
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
def with_serial_batch_numbers(execute):
@wraps(execute)
def wrapped(*args, **kwargs):
result = execute(*args, **kwargs)
if not result or len(result) < 2:
return result
columns, rows, *other = result
columns, rows = report_number_columns(columns, rows)
return columns, rows, *other
return wrapped
def report_number_columns(columns, rows):
from frappe.desk.query_report import get_column_as_dict
columns = [get_column_as_dict(column) for column in columns]
rows = [list(row) if isinstance(row, list | tuple) else row.copy() for row in rows]
for index, column in enumerate(list(columns)):
doctype = column.get("options")
field = column["fieldname"]
if doctype not in ("Serial No", "Batch"):
if field in ("serial_no", "balance_serial_no"):
doctype = "Serial No"
else:
continue
values = [row.get(field) if isinstance(row, dict) else row[index] for row in rows]
names = {name for value in values if value for name in str(value).split("\n")}
labels = SerialBatchIdentity(doctype).labels(names)
number_field = f"{field}_number"
columns.append({**column, "hidden": 1, "label": _("{0} ID").format(column["label"])})
columns[index] = {
"fieldname": number_field,
"label": column["label"],
"fieldtype": "Serial Batch Number",
"options": doctype,
"reference_field": field,
"width": column.get("width", 140),
"hidden": column.get("hidden", 0),
}
for row, value in zip(rows, values, strict=True):
number = "\n".join(labels.get(name, name) for name in str(value).split("\n")) if value else value
if isinstance(row, dict):
row[number_field] = number
else:
row.append(value)
row[index] = number
return 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 = [
row
for row in rows
if row.doctype != "Serial No" and (row.get("item_code") or row.get("rm_item_code"))
]
values = []
for row in serial_rows:
for field in fields:
meta = row.meta.get_field(field)
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))

View File

@@ -0,0 +1,245 @@
"""Resolve physical numbers at input boundaries. Stock references always contain document IDs."""
import frappe
from frappe import _
from frappe.model.naming import make_autoname
from frappe.utils import cstr, now
class SerialBatchIdentity:
def __init__(self, doctype):
self.doctype = doctype
self.item_field, self.number_field = {
"Serial No": ("item_code", "serial_no"),
"Batch": ("item", "batch_id"),
}[doctype]
def resolve(self, item_code, numbers, *, create=False, defaults=None):
"""Return IDs in input order. A physical number is never looked up as a document ID."""
if not isinstance(numbers, list | tuple) or any(not isinstance(number, str) for number in numbers):
frappe.throw(_("Physical numbers must be a list of strings"))
numbers = [number.strip() for number in numbers]
if not numbers:
return []
if not isinstance(item_code, str) or not item_code or any(not number for number in numbers):
frappe.throw(_("Item and physical number are required"))
filters = {self.item_field: item_code, self.number_field: ("in", numbers)}
records = frappe.get_all(self.doctype, filters=filters, fields=["name", self.number_field])
ids = {row[self.number_field]: row.name for row in records}
missing = []
for number in dict.fromkeys(numbers):
if number in ids:
continue
# Use the database comparison rules, including its collation, for exact lookups.
name = (
frappe.db.get_value(self.doctype, {self.item_field: item_code, self.number_field: number})
if records
else None
)
if not name and create:
missing.append(number)
continue
if not name:
frappe.throw(_("{0} {1} does not exist for Item {2}").format(self.doctype, number, item_code))
ids[number] = name
if missing:
ids.update(self.create_many(item_code, missing, defaults))
return [ids[number] for number in numbers]
def create_many(self, item_code, numbers, defaults=None):
if self.doctype == "Batch":
return {number: self.create_batch(item_code, number, defaults) for number in numbers}
# Inactive serials can be prepared before their first receipt assigns a company.
item = frappe.get_cached_value(
"Item", item_code, ["item_name", "description", "warranty_period", "has_serial_no"], as_dict=True
)
if not item.has_serial_no:
frappe.throw(_("Item {0} does not have serial numbers enabled").format(item_code))
common = {
"item_code": item_code,
"item_name": item.item_name,
"description": item.description,
"warranty_period": item.warranty_period or 0,
"status": "Inactive",
"creation": now(),
"modified": now(),
"owner": frappe.session.user,
"modified_by": frappe.session.user,
**(defaults or {}),
}
ids = {number: make_autoname("hash", "Serial No") for number in numbers}
try:
frappe.db.bulk_insert(
"Serial No",
fields=["name", "serial_no", *common],
values=[(name, number, *common.values()) for number, name in ids.items()],
)
except Exception as error:
if frappe.db.is_unique_key_violation(error) or frappe.db.is_primary_key_violation(error):
frappe.throw(
_("A serial number already exists for Item {0}. Refresh and try again.").format(
item_code
),
frappe.DuplicateEntryError,
)
raise
return ids
def create_batch(self, item_code, number, defaults=None):
doc = frappe.new_doc("Batch")
doc.update(defaults or {})
doc.item = item_code
doc.batch_id = number
doc.insert(ignore_permissions=True)
return doc.name
def labels(self, names):
if not names:
return {}
return dict(
frappe.get_all(
self.doctype,
filters={"name": ("in", list(set(names)))},
fields=["name", self.number_field],
as_list=True,
)
)
def validate(self, doc):
number = cstr(doc.get(self.number_field)).strip()
doc.set(self.number_field, number)
filters = {self.item_field: doc.get(self.item_field), self.number_field: number}
if doc.name:
filters["name"] = ("!=", doc.name)
if number and frappe.db.exists(self.doctype, filters):
frappe.throw(
_("{0} {1} already exists for Item {2}").format(
self.doctype, number, doc.get(self.item_field)
),
frappe.DuplicateEntryError,
)
def backfill_numbers(self):
table = frappe.qb.DocType(self.doctype)
frappe.qb.update(table).set(table[self.number_field], table.name).where(
table[self.number_field].isnull() | (table[self.number_field] == "")
).run()
def sync_constraint(self):
self.backfill_numbers()
frappe.db.add_unique(self.doctype, [self.item_field, self.number_field])
@frappe.whitelist(methods=["POST"])
def resolve_serial_batch_numbers(
item_code: str,
serial_numbers: list | str | None = None,
batch_numbers: list | str | None = None,
create: bool = False,
):
"""Resolve physical input to Link values. Existing ID-based APIs keep their meaning."""
frappe.has_permission("Item", "read", doc=item_code, throw=True)
result = {}
for doctype, values, key in (
("Serial No", serial_numbers, "serial_nos"),
("Batch", batch_numbers, "batch_nos"),
):
values = frappe.parse_json(values) or []
if values:
permission = "create" if create else "select" if frappe.only_has_select_perm(doctype) else "read"
frappe.has_permission(doctype, permission, throw=True)
result[key] = SerialBatchIdentity(doctype).resolve(item_code, values, create=create)
if values and not create:
allowed = frappe.get_list(
doctype, filters={"name": ("in", result[key])}, pluck="name", limit_page_length=0
)
if set(result[key]) - set(allowed):
frappe.throw(
_("Not permitted to select these serial or batch records"), frappe.PermissionError
)
return result
@frappe.whitelist(methods=["GET", "POST"])
def get_serial_batch_labels(doctype: str, names: list | str):
if doctype not in ("Serial No", "Batch"):
frappe.throw(_("Only Serial No and Batch labels are supported"))
names = frappe.parse_json(names)
if not isinstance(names, list) or any(not isinstance(name, str) for name in names):
frappe.throw(_("Document IDs must be a list of strings"))
identity = SerialBatchIdentity(doctype)
return dict(
frappe.get_list(
doctype,
filters={"name": ("in", names)},
fields=["name", identity.number_field],
as_list=True,
limit_page_length=0,
)
)
@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))
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)
frappe.has_permission("Serial No", "read", throw=True)
create = parent.doctype in SUPPORTED_VOUCHER_TYPES and get_type_of_transaction(parent, row) == "Inward"
return SerialBatchIdentity("Serial No").resolve(
row.item_code or row.rm_item_code,
frappe.parse_json(numbers),
create=create,
defaults={"company": parent.company},
)
def add_number_labels(entries):
"""Attach display values without changing the references or their field names."""
for field, doctype, label in (
("serial_no", "Serial No", "serial_number"),
("batch_no", "Batch", "batch_number"),
):
labels = SerialBatchIdentity(doctype).labels([row.get(field) for row in entries if row.get(field)])
for row in entries:
row[label] = labels.get(row.get(field), row.get(field))
return entries
def resolve_number_entries(item_code, entries, *, create=False):
"""Only explicit physical-number fields are resolved. Link fields already contain IDs."""
for field, doctype, number_field in (
("batch_no", "Batch", "batch_number"),
("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
)
for row, name in zip(rows, ids, strict=True):
row[field] = name
return entries
def validate_item_merge(old, new):
for doctype in ("Serial No", "Batch"):
identity = SerialBatchIdentity(doctype)
source = frappe.qb.DocType(doctype).as_("source")
target = frappe.qb.DocType(doctype).as_("target")
conflict = (
frappe.qb.from_(source)
.join(target)
.on(source[identity.number_field] == target[identity.number_field])
.select(source[identity.number_field])
.where((source[identity.item_field] == old) & (target[identity.item_field] == new))
.limit(1)
).run()
if conflict:
frappe.throw(_("Cannot merge items with the same {0}: {1}").format(doctype, conflict[0][0]))

View File

@@ -521,6 +521,32 @@ class TestGetItemDetail(ERPNextTestSuite):
)
self.assertEqual({d.batch_no: d.qty for d in entries}, {batches[0]: -2, batches[1]: -3})
def test_scanned_serial_and_batch_preserved_during_item_selection(self):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
item_code, batches = self.make_batched_item_with_stock(
[1, 1], has_serial_no=1, serial_no_series="SCAN-SN-.#####"
)
serial_no = frappe.db.get_value("Serial No", {"item_code": item_code, "batch_no": batches[1]})
with self.change_settings(
"Stock Settings",
{"pick_serial_and_batch_based_on": "FIFO", "auto_create_serial_and_batch_bundle_for_outward": 1},
):
self.assertEqual(self.get_picked_batch_no(item_code, 1), batches[0])
dn = create_delivery_note(
item_code=item_code,
qty=1,
use_serial_batch_fields=1,
serial_no=serial_no,
batch_no=batches[1],
do_not_save=True,
)
dn.process_item_selection(item_idx=dn.items[0].idx, reset_item_details=True)
self.assertEqual(dn.items[0].serial_no, serial_no)
self.assertEqual(dn.items[0].batch_no, batches[1])
self.assertEqual(dn.items[0].qty, 1)
def test_serial_nos_picked_across_batches_when_no_batch_covers_qty(self):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (

View File

@@ -0,0 +1,304 @@
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.doctype.serial_and_batch_bundle.inline_editor import (
get_bundle_entries,
upsert_bundle_entries,
)
from erpnext.stock.serial_batch_bundle import get_serial_or_batch_nos
from erpnext.stock.serial_batch_identity import (
SerialBatchIdentity,
resolve_number_entries,
validate_item_merge,
)
from erpnext.stock.utils import scan_barcode
from erpnext.tests.utils import ERPNextTestSuite
class TestSerialBatchIdentity(ERPNextTestSuite):
def make_item(self, serialized=False):
return make_item(properties={"has_serial_no": int(serialized), "has_batch_no": int(not serialized)})
def create_number(self, item, number, serialized=False):
identity = SerialBatchIdentity("Serial No" if serialized else "Batch")
return identity.resolve(item.name, [number], create=True, defaults={"company": "_Test Company"})[0]
def test_numbers_are_unique_within_item(self):
for serialized in (False, True):
identity = SerialBatchIdentity("Serial No" if serialized else "Batch")
items = [self.make_item(serialized) for _ in range(2)]
number = frappe.generate_hash()
ids = [self.create_number(item, number, serialized) for item in items]
self.assertNotEqual(ids[0], ids[1])
self.assertNotIn(number, ids)
for item, name in zip(items, ids, strict=True):
self.assertEqual(identity.resolve(item.name, [number]), [name])
self.assertEqual(identity.resolve(item.name, [number], create=True), [name])
duplicate = frappe.copy_doc(frappe.get_doc(identity.doctype, ids[0]))
duplicate.set(identity.number_field, number)
with self.assertRaises(frappe.DuplicateEntryError):
duplicate.insert()
def test_database_rejects_duplicate_even_without_controller_validation(self):
for serialized in (False, True):
item = self.make_item(serialized)
name = self.create_number(item, frappe.generate_hash(), serialized)
doctype = "Serial No" if serialized else "Batch"
duplicate = frappe.get_doc(doctype, name)
duplicate.name = frappe.generate_hash()
frappe.db.savepoint("duplicate_number")
try:
with self.assertRaises((frappe.DuplicateEntryError, frappe.UniqueValidationError)):
duplicate.db_insert()
finally:
frappe.db.rollback(save_point="duplicate_number")
def test_legacy_id_is_not_used_to_resolve_another_items_number(self):
for serialized in (False, True):
identity = SerialBatchIdentity("Serial No" if serialized else "Batch")
old_item, new_item = self.make_item(serialized), self.make_item(serialized)
old_id = self.create_number(old_item, frappe.generate_hash(), serialized)
frappe.db.set_value(identity.doctype, old_id, identity.number_field, old_id)
new_id = self.create_number(new_item, old_id, serialized)
self.assertEqual(identity.resolve(old_item.name, [old_id]), [old_id])
self.assertEqual(identity.resolve(new_item.name, [old_id]), [new_id])
self.assertEqual(scan_barcode(old_id, {"item_code": new_item.name})["item_code"], new_item.name)
def test_ambiguous_scan_and_new_match(self):
for serialized in (False, True):
items = [self.make_item(serialized) for _ in range(2)]
number = frappe.generate_hash()
self.create_number(items[0], number, serialized)
self.assertEqual(scan_barcode(number)["item_code"], items[0].name)
self.create_number(items[1], number, serialized)
with self.assertRaises(frappe.ValidationError):
scan_barcode(number)
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_receipts_store_ids_and_display_numbers(self):
for serialized in (False, True):
number = frappe.generate_hash()
for item in [self.make_item(serialized) for _ in range(2)]:
pr = make_purchase_receipt(item_code=item.name, qty=1, rate=100, do_not_submit=True)
field = "serial_number" if serialized else "batch_number"
link = "serial_no" if serialized else "batch_no"
summary = upsert_bundle_entries(
pr.items[0].as_dict(), pr.as_dict(), [{field: number, "qty": 1}]
)
pr.items[0].serial_and_batch_bundle = summary.bundle
pr.save()
pr.submit()
rows = get_bundle_entries(summary.bundle)["entries"]
self.assertNotEqual(rows[0][link], number)
self.assertEqual(rows[0][field], number)
self.assertIn(number, get_serial_or_batch_nos(summary.bundle))
self.assertNotIn(rows[0][link], get_serial_or_batch_nos(summary.bundle))
pr.cancel()
def test_explicit_links_are_not_reinterpreted(self):
item = self.make_item(True)
name = self.create_number(item, frappe.generate_hash(), True)
rows = [{"serial_no": name}]
resolve_number_entries(item.name, rows)
self.assertEqual(rows, [{"serial_no": name}])
def test_item_merge_rejects_shared_numbers(self):
for serialized in (False, True):
items = [self.make_item(serialized) for _ in range(2)]
number = frappe.generate_hash()
for item in items:
self.create_number(item, number, serialized)
with self.assertRaises(frappe.ValidationError):
validate_item_merge(items[0].name, items[1].name)
def test_transfers_and_returns_keep_matching_numbers_separate(self):
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
for serialized in (False, True):
items = [self.make_item(serialized) for _ in range(2)]
number = frappe.generate_hash()
ids = [self.create_number(item, number, serialized) for item in items]
link = "serial_no" if serialized else "batch_no"
receipts = []
for item, name, rate in zip(items, ids, (11, 42), strict=True):
receipts.append(
make_purchase_receipt(
item_code=item.name, qty=1, rate=rate, **{link: [name] if serialized else name}
)
)
transfer = make_stock_entry(
item_code=items[0].name,
qty=1,
from_warehouse="_Test Warehouse - _TC",
to_warehouse="_Test Warehouse 1 - _TC",
**{link: [ids[0]] if serialized else ids[0]},
)
if serialized:
self.assertEqual(
frappe.db.get_value("Serial No", ids[1], "warehouse"), "_Test Warehouse - _TC"
)
issue = make_stock_entry(
item_code=items[0].name,
qty=1,
from_warehouse="_Test Warehouse 1 - _TC",
**{link: [ids[0]] if serialized else ids[0]},
)
self.assertEqual(
frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": issue.name, "is_cancelled": 0},
"stock_value_difference",
),
-11,
)
issue.cancel()
transfer.cancel()
purchase_return = make_purchase_return(receipts[0].name)
purchase_return.insert().submit()
if serialized:
self.assertEqual(
frappe.db.get_value("Serial No", ids[1], "warehouse"), "_Test Warehouse - _TC"
)
purchase_return.cancel()
for receipt in receipts:
receipt.cancel()
def test_combined_serial_batch_csv_resolves_both_links(self):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
parse_csv_file_to_get_serial_batch,
)
item = make_item(properties={"has_serial_no": 1, "has_batch_no": 1})
serials, batches = parse_csv_file_to_get_serial_batch(
[["Serial No", "Batch No", "Quantity"], ["physical-serial", "physical-batch", "1"]]
)
resolve_number_entries(item.name, serials, create=True)
resolve_number_entries(item.name, batches, create=True)
self.assertEqual(serials[0]["batch_no"], batches[0]["batch_no"])
self.assertNotEqual(serials[0]["serial_no"], "physical-serial")
self.assertNotEqual(serials[0]["batch_no"], "physical-batch")
def test_report_keeps_ids_and_exposes_physical_columns(self):
from erpnext.stock.serial_batch_display import report_number_columns
item = self.make_item(True)
name = self.create_number(item, "physical-report-number", True)
columns, rows = report_number_columns(
[{"label": "Serial No", "fieldname": "serial_no", "fieldtype": "Link", "options": "Serial No"}],
[{"serial_no": name}],
)
self.assertEqual(rows[0]["serial_no"], name)
self.assertEqual(rows[0]["serial_no_number"], "physical-report-number")
self.assertEqual(columns[1]["hidden"], 1)
self.assertFalse(columns[0]["hidden"])
def test_migration_backfill_preserves_stock_references(self):
for serialized in (False, True):
identity = SerialBatchIdentity("Serial No" if serialized else "Batch")
item = self.make_item(serialized)
name = self.create_number(item, "legacy-number", serialized)
link = "serial_no" if serialized else "batch_no"
pr = make_purchase_receipt(
item_code=item.name, qty=1, rate=100, **{link: [name] if serialized else name}
)
bundle = pr.items[0].serial_and_batch_bundle
before = frappe.get_doc("Serial and Batch Bundle", bundle).as_dict()
frappe.db.set_value(identity.doctype, name, identity.number_field, "")
identity.backfill_numbers()
identity.backfill_numbers()
self.assertEqual(frappe.db.get_value(identity.doctype, name, identity.number_field), name)
self.assertEqual(frappe.get_doc("Serial and Batch Bundle", bundle).as_dict(), before)
pr.cancel()
def test_print_formats_physical_serials_without_changing_stored_ids(self):
from erpnext.stock.serial_batch_display import before_print
item = self.make_item(True)
name = self.create_number(item, "PRINT-123", True)
pr = make_purchase_receipt(item_code=item.name, qty=1, rate=100, serial_no=[name])
print_doc = frappe.get_doc("Purchase Receipt", pr.name)
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")
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(
{
"doctype": "Print Format",
"name": "Serial Identity Test Print",
"doc_type": "Purchase Receipt",
"print_format_type": "Jinja",
"custom_format": 1,
"html": "{{ get_serial_or_batch_nos(doc.items[0].serial_and_batch_bundle) }}",
}
).insert()
printed = frappe.get_print("Purchase Receipt", pr.name, print_format=print_format.name)
self.assertIn("PRINT-123", printed)
self.assertNotIn(name, printed)
pr.cancel()
def test_number_search_returns_ids_and_physical_titles(self):
from erpnext.stock.report.serial_and_batch_summary.serial_and_batch_summary import get_number_options
for serialized in (False, True):
identity = SerialBatchIdentity("Serial No" if serialized else "Batch")
item = self.make_item(serialized)
name = self.create_number(item, "SEARCH-123", serialized)
pr = make_purchase_receipt(
item_code=item.name,
qty=1,
rate=100,
**({"serial_no": [name]} if serialized else {"batch_no": name}),
)
for filters in ({"item_code": item.name}, {"voucher_no": [pr.name]}):
options = get_number_options(identity.doctype, "SEARCH", 0, 20, filters)
self.assertEqual([tuple(row) for row in options], [(name, "SEARCH-123")])
self.assertEqual(
[tuple(row) for row in get_number_options(identity.doctype, name, 0, 20, filters)],
[(name, "SEARCH-123")],
)
pr.cancel()
def test_combined_serial_batch_receipts_keep_items_separate(self):
for _item in range(2):
item = make_item(properties={"has_serial_no": 1, "has_batch_no": 1})
pr = make_purchase_receipt(item_code=item.name, qty=1, rate=100, do_not_submit=True)
summary = upsert_bundle_entries(
pr.items[0].as_dict(),
pr.as_dict(),
[{"serial_number": "COMBINED-SERIAL", "batch_number": "COMBINED-BATCH", "qty": 1}],
)
pr.items[0].serial_and_batch_bundle = summary.bundle
pr.save().submit()
entry = frappe.get_doc("Serial and Batch Bundle", summary.bundle).entries[0]
self.assertNotEqual(entry.serial_no, "COMBINED-SERIAL")
self.assertNotEqual(entry.batch_no, "COMBINED-BATCH")
self.assertEqual(frappe.db.get_value("Serial No", entry.serial_no, "batch_no"), entry.batch_no)
self.assertEqual(frappe.db.get_value("Batch", entry.batch_no, "item"), item.name)
pr.cancel()
def test_pos_search_returns_all_matching_items(self):
from erpnext.selling.page.point_of_sale.point_of_sale import search_by_term
for serialized in (False, True):
items = [self.make_item(serialized) for _ in range(2)]
number = "POS-" + frappe.generate_hash()
ids = [self.create_number(item, number, serialized) for item in items]
result = search_by_term(number, "_Test Warehouse - _TC", "Standard Selling")
self.assertTrue(result["requires_selection"])
self.assertEqual({row["item_code"] for row in result["items"]}, {item.name for item in items})
field = "serial_no" if serialized else "batch_no"
self.assertEqual({row[field] for row in result["items"]}, set(ids))
def test_empty_batch_link_validation_accepts_the_resolved_id(self):
from erpnext.controllers.queries import get_batch_no
item = self.make_item()
name = self.create_number(item, "EMPTY-BATCH")
for text in ("EMPTY-BATCH", name):
options = get_batch_no("Batch", text, "name", 0, 20, {"item_code": item.name, "is_inward": 1})
self.assertEqual([(row[0], row[1]) for row in options], [(name, "EMPTY-BATCH")])

View File

@@ -78,7 +78,7 @@ class TestStockUtilities(ERPNextTestSuite, StockTestMixin):
batch_item = self.make_item(properties={"has_batch_no": 1, "create_new_batch": 1})
batch = frappe.get_doc(doctype="Batch", item=batch_item.name).insert()
batch_scan = scan_barcode(batch.name)
batch_scan = scan_barcode(batch.batch_id)
self.assertEqual(batch_scan["item_code"], batch_item.name)
self.assertEqual(batch_scan["batch_no"], batch.name)
self.assertEqual(batch_scan["has_batch_no"], 1)
@@ -92,7 +92,7 @@ class TestStockUtilities(ERPNextTestSuite, StockTestMixin):
company="_Test Company",
).insert()
serial_scan = scan_barcode(serial.name)
serial_scan = scan_barcode(serial.serial_no)
self.assertEqual(serial_scan["item_code"], serial_item.name)
self.assertEqual(serial_scan["serial_no"], serial.name)
self.assertEqual(serial_scan["has_batch_no"], 0)
@@ -182,7 +182,7 @@ class TestStockUtilities(ERPNextTestSuite, StockTestMixin):
serial_nos = []
for rate in (10, 30):
sn = "_TAVG" + random_string(8)
frappe.get_doc(
serial = frappe.get_doc(
{
"doctype": "Serial No",
"serial_no": sn,
@@ -191,6 +191,6 @@ class TestStockUtilities(ERPNextTestSuite, StockTestMixin):
"purchase_rate": rate,
}
).insert()
serial_nos.append(sn)
serial_nos.append(serial.name)
self.assertEqual(flt(get_avg_purchase_rate("\n".join(serial_nos))), 20.0)

View File

@@ -596,72 +596,57 @@ def check_pending_reposting(posting_date: str, company: str | None = None, throw
return bool(reposting_pending)
@frappe.whitelist()
def scan_barcode(search_value: str, ctx: dict | str | None = None) -> BarcodeScanResult:
def set_cache(data: BarcodeScanResult):
frappe.cache().set_value(f"erpnext:barcode_scan:{search_value}", data, expires_in_sec=120)
_update_item_info(data, ctx)
def get_cache() -> BarcodeScanResult | None:
data = frappe.cache().get_value(f"erpnext:barcode_scan:{search_value}")
if not data:
return
_update_item_info(data, ctx)
return data
if ctx is None:
ctx = frappe._dict()
if scan_data := get_cache():
return scan_data
# search barcode no
barcode_data = frappe.db.get_value(
"Item Barcode",
{"barcode": search_value},
["barcode", "parent as item_code", "uom"],
as_dict=True,
@frappe.whitelist(methods=["GET", "POST"])
def scan_barcode(search_value: str, ctx: dict | str | None = None, allow_multiple: bool = False) -> dict:
ctx = frappe._dict(frappe.parse_json(ctx) or {})
if ctx.item_code and not isinstance(ctx.item_code, str):
frappe.throw(_("Item Code must be a string"))
search_value = search_value.strip()
candidates = []
barcode_filters = {"barcode": search_value}
if ctx.item_code:
barcode_filters["parent"] = ctx.item_code
barcode = frappe.db.get_value(
"Item Barcode", barcode_filters, ["barcode", "parent as item_code", "uom"], as_dict=True
)
if barcode_data:
set_cache(barcode_data)
return barcode_data
if barcode:
candidates.append(barcode)
# search serial no
serial_no_data = frappe.db.get_value(
"Serial No",
search_value,
["name as serial_no", "item_code", "batch_no"],
as_dict=True,
)
if serial_no_data:
set_cache(serial_no_data)
return serial_no_data
for doctype, number_field, item_field, fields in (
(
"Serial No",
"serial_no",
"item_code",
["name as serial_no", "serial_no as serial_number", "item_code", "batch_no"],
),
("Batch", "batch_id", "item", ["name as batch_no", "batch_id as batch_number", "item as item_code"]),
):
if not frappe.has_permission(doctype, "read"):
continue
filters = {number_field: search_value}
if ctx.item_code:
filters[item_field] = ctx.item_code
if doctype == "Batch":
filters["disabled"] = 0
candidates.extend(frappe.get_list(doctype, filters=filters, fields=fields, limit_page_length=0))
# search batch no
batch_no_data = frappe.db.get_value(
"Batch",
search_value,
["name as batch_no", "item as item_code"],
as_dict=True,
)
if batch_no_data:
if frappe.get_cached_value("Item", batch_no_data.item_code, "has_serial_no"):
frappe.throw(
_(
"Batch No {0} is linked with Item {1} which has serial no. Please scan serial no instead."
).format(search_value, batch_no_data.item_code)
)
for candidate in candidates:
_update_item_info(candidate, ctx)
set_cache(batch_no_data)
return batch_no_data
if len(candidates) > 1:
if allow_multiple:
return {"candidates": candidates}
frappe.throw(_("This number matches multiple records. Select the item and serial or batch record."))
if candidates:
candidate = candidates[0]
if candidate.get("batch_no") and not candidate.get("serial_no") and candidate.get("has_serial_no"):
frappe.throw(_("Please scan a serial number for Item {0}").format(candidate.item_code))
return candidate
warehouse = frappe.get_cached_value("Warehouse", search_value, ("name", "disabled"), as_dict=True)
if warehouse and not warehouse.disabled:
warehouse_data = {"warehouse": warehouse.name}
set_cache(warehouse_data)
return warehouse_data
return {"warehouse": warehouse.name}
return {}