diff --git a/erpnext/change_log/current/serial_batch_identity.md b/erpnext/change_log/current/serial_batch_identity.md new file mode 100644 index 00000000000..688586fb16a --- /dev/null +++ b/erpnext/change_log/current/serial_batch_identity.md @@ -0,0 +1,25 @@ +# Serial and batch numbers are unique per item + +Serial No and Batch now have generated document IDs. Their physical numbers remain in `Serial No.serial_no` and `Batch.batch_id`. +Different items can use the same physical number. Two records for the same item cannot share a number. + +Existing document IDs remain unchanged. Migration fills missing physical numbers from those IDs and replaces the global unique indexes. +Historical transactions, bundles, and stock valuations retain their references. Item merges fail when they would introduce duplicate numbers. + +When a scanned number matches several items, select the item from the filtered Item field. + +![Select an item when a scanned number matches several items](serial_batch_item_picker.png) + +## Integration changes for the next major release + +- Treat `name` as the document ID. Do not construct it from the physical number. +- Use returned document IDs in transaction fields and Serial and Batch Entry links, including legacy serial-number text lists. +- Resolve physical numbers with `erpnext.stock.serial_batch_identity.resolve_serial_batch_numbers`. Supply `item_code` and lists named `serial_numbers` or `batch_numbers`. +- The resolver returns `serial_nos` and `batch_nos`, containing document IDs in input order. Set `create` only for authorized creation. +- For the bundle editor, use `serial_number` and `batch_number` for physical input. Use `serial_no` and `batch_no` for existing links. +- CSV uploads continue accepting physical numbers. CSV downloads and standard prints show physical numbers. +- A barcode scan can match several records. Interactive callers pass `allow_multiple=true` and select a returned candidate before updating a transaction. +- Reports retain ID columns as hidden fields and provide separate visible physical-number columns. Report filters still accept document IDs. + +Custom integrations and print formats must use physical-number fields for labels and document IDs for links. +After new duplicate numbers exist, restoring an older release requires restoring the pre-upgrade database backup. diff --git a/erpnext/change_log/current/serial_batch_item_picker.png b/erpnext/change_log/current/serial_batch_item_picker.png new file mode 100644 index 00000000000..e95649bd801 Binary files /dev/null and b/erpnext/change_log/current/serial_batch_item_picker.png differ diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 8d72144925f..81a889e804b 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -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}%") ) ) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 7f353808fbb..3e1f714ddb1 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -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", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index cad669ee10e..572fe4acd15 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -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")) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index b2320a6f455..dc1f30710db 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -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 diff --git a/erpnext/patches/separate_serial_batch_identity.py b/erpnext/patches/separate_serial_batch_identity.py new file mode 100644 index 00000000000..31e826957a1 --- /dev/null +++ b/erpnext/patches/separate_serial_batch_identity.py @@ -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() diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js index 746a0d5e392..37345495d93 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -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"; diff --git a/erpnext/public/js/tests/barcode_scanner.test.cjs b/erpnext/public/js/tests/barcode_scanner.test.cjs new file mode 100644 index 00000000000..4b6e9d17fd2 --- /dev/null +++ b/erpnext/public/js/tests/barcode_scanner.test.cjs @@ -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); +}); diff --git a/erpnext/public/js/tests/serial_batch_input.test.cjs b/erpnext/public/js/tests/serial_batch_input.test.cjs new file mode 100644 index 00000000000..2f0675936e6 --- /dev/null +++ b/erpnext/public/js/tests/serial_batch_input.test.cjs @@ -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( + "", + field, + { for_print: true }, + { serial_no: "internal-id" } + ), + "<serial>" + ); +}); diff --git a/erpnext/public/js/utils/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js index 8f0c618cf97..43984c81310 100644 --- a/erpnext/public/js/utils/barcode_scanner.js +++ b/erpnext/public/js/utils/barcode_scanner.js @@ -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; } diff --git a/erpnext/public/js/utils/serial_batch_display.js b/erpnext/public/js/utils/serial_batch_display.js new file mode 100644 index 00000000000..06e330bfa2d --- /dev/null +++ b/erpnext/public/js/utils/serial_batch_display.js @@ -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("
"); +}; diff --git a/erpnext/public/js/utils/serial_batch_inline_editor.js b/erpnext/public/js/utils/serial_batch_inline_editor.js index 1e5ab205e4b..27de336fdd6 100644 --- a/erpnext/public/js/utils/serial_batch_inline_editor.js +++ b/erpnext/public/js/utils/serial_batch_inline_editor.js @@ -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 ` @@ -957,8 +976,12 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor { )}" style="cursor: pointer;">${batch_no}` : "" } - ${ - !d.serial_no && show_batch ? this.get_qty_input(d, qty) : this.format_float(qty) + ${ + !(d.serial_no || d.serial_number) && show_batch + ? this.get_qty_input(d, qty) + : this.format_float(qty) } `; }) @@ -975,10 +998,30 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor { ${base_count + index + 1} - ${show_serial ? `${this.esc(d.serial_no || "")}` : ""} - ${show_batch ? `${this.esc(d.batch_no || "")}` : ""} - ${ - !d.serial_no && show_batch + ${ + show_serial + ? `${this.esc( + d.serial_number || + frappe.utils.get_link_title("Serial No", d.serial_no) || + d.serial_no || + "" + )}` + : "" + } + ${ + show_batch + ? `${this.esc( + d.batch_number || + frappe.utils.get_link_title("Batch", d.batch_no) || + d.batch_no || + "" + )}` + : "" + } + ${ + !(d.serial_no || d.serial_number) && show_batch ? this.get_pending_qty_input(d, index) : this.format_float(d.qty) } diff --git a/erpnext/public/js/utils/serial_batch_input.js b/erpnext/public/js/utils/serial_batch_input.js new file mode 100644 index 00000000000..f6e2db917bf --- /dev/null +++ b/erpnext/public/js/utils/serial_batch_input.js @@ -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); +} diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 95a2278e2c8..5db48458c95 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -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) => { diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py index 39621ff9fb0..6282b7ee812 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -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): diff --git a/erpnext/selling/page/point_of_sale/pos_item_details.js b/erpnext/selling/page/point_of_sale/pos_item_details.js index ae298128a3b..6d074cade85 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_details.js +++ b/erpnext/selling/page/point_of_sale/pos_item_details.js @@ -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]); }); diff --git a/erpnext/selling/page/point_of_sale/pos_item_selector.js b/erpnext/selling/page/point_of_sale/pos_item_selector.js index e09bc3c3413..46329031fd4 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_selector.js +++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js @@ -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); diff --git a/erpnext/stock/doctype/batch/batch.json b/erpnext/stock/doctype/batch/batch.json index ff90e4f1697..6d3faf4b4f3 100644 --- a/erpnext/stock/doctype/batch/batch.json +++ b/erpnext/stock/doctype/batch/batch.json @@ -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" } diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index fac593c45be..ef9b1707d87 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -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() diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index cc7b55031c1..21002ce2a1a 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -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 diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index df0a277dc5b..d4d7757583c 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -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") diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 9de827be31a..1725ae8719c 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -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) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 71ba769d944..7b398fdb257 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -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( diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index af76a4475f6..692fd4c117a 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -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, diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 5e1e8b1c3e2..b5b7473ef11 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -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" diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/inline_editor.py b/erpnext/stock/doctype/serial_and_batch_bundle/inline_editor.py index 433c2bf0016..83fb2a230c1 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/inline_editor.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/inline_editor.py @@ -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", diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 4e1d728ab03..e237e00ba3e 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -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() diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_inline_editor.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_inline_editor.py index 9c2743aa36e..227c431012f 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_inline_editor.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_inline_editor.py @@ -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"}]), ) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 5d2c919c314..d20dc8093e2 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -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"): diff --git a/erpnext/stock/doctype/serial_no/serial_no.json b/erpnext/stock/doctype/serial_no/serial_no.json index 93404979b0a..a13637ea94c 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.json +++ b/erpnext/stock/doctype/serial_no/serial_no.json @@ -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 } diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index a8d9b9f1e7d..c9c363df983 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -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"""
{item_code}: {len(serial_nos)} Serial Numbers @@ -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"]) diff --git a/erpnext/stock/doctype/serial_no/test_serial_no.py b/erpnext/stock/doctype/serial_no/test_serial_no.py index 0e93ff38b8b..b1501a01503 100644 --- a/erpnext/stock/doctype/serial_no/test_serial_no.py +++ b/erpnext/stock/doctype/serial_no/test_serial_no.py @@ -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")) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index bc3f9ae9fbd..bb9419266ef 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -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, diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 47ce73872a7..d97256ddb3a 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -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) diff --git a/erpnext/stock/report/available_batch_report/available_batch_report.py b/erpnext/stock/report/available_batch_report/available_batch_report.py index 3edcd88eb37..1cd16cb4e0c 100644 --- a/erpnext/stock/report/available_batch_report/available_batch_report.py +++ b/erpnext/stock/report/available_batch_report/available_batch_report.py @@ -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) diff --git a/erpnext/stock/report/available_serial_no/available_serial_no.py b/erpnext/stock/report/available_serial_no/available_serial_no.py index 5d922686ddd..0f7a1d1082d 100644 --- a/erpnext/stock/report/available_serial_no/available_serial_no.py +++ b/erpnext/stock/report/available_serial_no/available_serial_no.py @@ -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) diff --git a/erpnext/stock/report/batch_item_expiry_status/batch_item_expiry_status.py b/erpnext/stock/report/batch_item_expiry_status/batch_item_expiry_status.py index 3820b3e1566..9299b5347ca 100644 --- a/erpnext/stock/report/batch_item_expiry_status/batch_item_expiry_status.py +++ b/erpnext/stock/report/batch_item_expiry_status/batch_item_expiry_status.py @@ -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) diff --git a/erpnext/stock/report/batch_item_expiry_status/test_batch_item_expiry_status.py b/erpnext/stock/report/batch_item_expiry_status/test_batch_item_expiry_status.py index 2cd50a59510..7e91b7871cf 100644 --- a/erpnext/stock/report/batch_item_expiry_status/test_batch_item_expiry_status.py +++ b/erpnext/stock/report/batch_item_expiry_status/test_batch_item_expiry_status.py @@ -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") diff --git a/erpnext/stock/report/batch_split_tree/batch_split_tree.py b/erpnext/stock/report/batch_split_tree/batch_split_tree.py index 5f0b90d7fa0..49a2a2c58ae 100644 --- a/erpnext/stock/report/batch_split_tree/batch_split_tree.py +++ b/erpnext/stock/report/batch_split_tree/batch_split_tree.py @@ -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) diff --git a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py index 12bc74fe5b4..93a1268a5f7 100644 --- a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +++ b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py @@ -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 = {} diff --git a/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.py b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.py index f885b78c98d..aa4bb0d0f73 100644 --- a/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.py +++ b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.py @@ -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) diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py index c7a256c7c57..d69706044d0 100644 --- a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py +++ b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py @@ -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() diff --git a/erpnext/stock/report/negative_batch_report/negative_batch_report.py b/erpnext/stock/report/negative_batch_report/negative_batch_report.py index f29fe3b8d63..ddbedd753dd 100644 --- a/erpnext/stock/report/negative_batch_report/negative_batch_report.py +++ b/erpnext/stock/report/negative_batch_report/negative_batch_report.py @@ -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. diff --git a/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py b/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py index 39e707d3b77..f7b848e94cb 100644 --- a/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py +++ b/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py @@ -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, + ) diff --git a/erpnext/stock/report/serial_no_and_batch_traceability/serial_no_and_batch_traceability.js b/erpnext/stock/report/serial_no_and_batch_traceability/serial_no_and_batch_traceability.js index d17b38d7f8f..e92fbd6c408 100644 --- a/erpnext/stock/report/serial_no_and_batch_traceability/serial_no_and_batch_traceability.js +++ b/erpnext/stock/report/serial_no_and_batch_traceability/serial_no_and_batch_traceability.js @@ -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 `${frappe.utils.escape_html(original_value)}`; + )}">${frappe.utils.escape_html(number)}`; } 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, diff --git a/erpnext/stock/report/serial_no_and_batch_traceability/serial_no_and_batch_traceability.py b/erpnext/stock/report/serial_no_and_batch_traceability/serial_no_and_batch_traceability.py index 493313ed9e6..552ed9549b0 100644 --- a/erpnext/stock/report/serial_no_and_batch_traceability/serial_no_and_batch_traceability.py +++ b/erpnext/stock/report/serial_no_and_batch_traceability/serial_no_and_batch_traceability.py @@ -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() diff --git a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py index b73f8dde3b2..e8f60d01ab2 100644 --- a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py +++ b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py @@ -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) diff --git a/erpnext/stock/report/serial_no_service_contract_expiry/serial_no_service_contract_expiry.json b/erpnext/stock/report/serial_no_service_contract_expiry/serial_no_service_contract_expiry.json index 75e2fac98fd..1be463e91de 100644 --- a/erpnext/stock/report/serial_no_service_contract_expiry/serial_no_service_contract_expiry.json +++ b/erpnext/stock/report/serial_no_service_contract_expiry/serial_no_service_contract_expiry.json @@ -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", diff --git a/erpnext/stock/report/serial_no_status/serial_no_status.json b/erpnext/stock/report/serial_no_status/serial_no_status.json index d74c2087f72..ae3bac1be9a 100644 --- a/erpnext/stock/report/serial_no_status/serial_no_status.json +++ b/erpnext/stock/report/serial_no_status/serial_no_status.json @@ -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", diff --git a/erpnext/stock/report/serial_no_warranty_expiry/serial_no_warranty_expiry.json b/erpnext/stock/report/serial_no_warranty_expiry/serial_no_warranty_expiry.json index 2f6acad6557..84200b3e670 100644 --- a/erpnext/stock/report/serial_no_warranty_expiry/serial_no_warranty_expiry.json +++ b/erpnext/stock/report/serial_no_warranty_expiry/serial_no_warranty_expiry.json @@ -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", diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index 5414274db7e..dde791436c4 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -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") diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index 9ebf3c11528..7a8b73826b7 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -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) diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py index e72ab8cee4a..cf6d364ca1c 100644 --- a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py +++ b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py @@ -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 = [], [] diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py index 46c0c5da4ea..cd26028f414 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py @@ -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 = {} diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index bed71974b87..51418631455 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -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 = "" for d in data: if d.serial_no: - html += f"" + html += f"" else: - html += f"" + html += f"" html += "
{d.batch_no}{d.serial_no}{abs(d.qty)}
{escape_html(d.batch_number or '')}{escape_html(d.serial_number or '')}{abs(d.qty)}
{d.batch_no}{abs(d.qty)}
{escape_html(d.batch_number or '')}{abs(d.qty)}
" @@ -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): diff --git a/erpnext/stock/serial_batch_display.py b/erpnext/stock/serial_batch_display.py new file mode 100644 index 00000000000..e1c3fd4d7f6 --- /dev/null +++ b/erpnext/stock/serial_batch_display.py @@ -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)) diff --git a/erpnext/stock/serial_batch_identity.py b/erpnext/stock/serial_batch_identity.py new file mode 100644 index 00000000000..a694bec94c7 --- /dev/null +++ b/erpnext/stock/serial_batch_identity.py @@ -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])) diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index 7bb8fe32f4e..467bdb7c8f6 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -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 ( diff --git a/erpnext/stock/tests/test_serial_batch_identity.py b/erpnext/stock/tests/test_serial_batch_identity.py new file mode 100644 index 00000000000..07260de4c1e --- /dev/null +++ b/erpnext/stock/tests/test_serial_batch_identity.py @@ -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")]) diff --git a/erpnext/stock/tests/test_utils.py b/erpnext/stock/tests/test_utils.py index 7736fe49bae..fd16cd28f0d 100644 --- a/erpnext/stock/tests/test_utils.py +++ b/erpnext/stock/tests/test_utils.py @@ -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) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index ffd1d674b10..16e09ad8ce7 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -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 {}