mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-15 01:43:10 +00:00
feat(stock)!: separate serial and batch numbers from document IDs
This commit is contained in:
25
erpnext/change_log/current/serial_batch_identity.md
Normal file
25
erpnext/change_log/current/serial_batch_identity.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
BIN
erpnext/change_log/current/serial_batch_item_picker.png
Normal file
BIN
erpnext/change_log/current/serial_batch_item_picker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -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}%")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
10
erpnext/patches/separate_serial_batch_identity.py
Normal file
10
erpnext/patches/separate_serial_batch_identity.py
Normal 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()
|
||||
@@ -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";
|
||||
|
||||
176
erpnext/public/js/tests/barcode_scanner.test.cjs
Normal file
176
erpnext/public/js/tests/barcode_scanner.test.cjs
Normal 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);
|
||||
});
|
||||
256
erpnext/public/js/tests/serial_batch_input.test.cjs
Normal file
256
erpnext/public/js/tests/serial_batch_input.test.cjs
Normal 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("<", "<").replaceAll(">", ">"),
|
||||
},
|
||||
},
|
||||
};
|
||||
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" }
|
||||
),
|
||||
"<serial>"
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
19
erpnext/public/js/utils/serial_batch_display.js
Normal file
19
erpnext/public/js/utils/serial_batch_display.js
Normal 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>");
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
165
erpnext/public/js/utils/serial_batch_input.js
Normal file
165
erpnext/public/js/utils/serial_batch_input.js
Normal 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);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"}]),
|
||||
)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = [], []
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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):
|
||||
|
||||
81
erpnext/stock/serial_batch_display.py
Normal file
81
erpnext/stock/serial_batch_display.py
Normal 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))
|
||||
245
erpnext/stock/serial_batch_identity.py
Normal file
245
erpnext/stock/serial_batch_identity.py
Normal 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]))
|
||||
@@ -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 (
|
||||
|
||||
304
erpnext/stock/tests/test_serial_batch_identity.py
Normal file
304
erpnext/stock/tests/test_serial_batch_identity.py
Normal 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")])
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user