From ca880f6be70b683a0fd719ccf70a145c42386df7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 8 Sep 2026 12:00:05 +0530 Subject: [PATCH] feat(stock)!: separate serial and batch numbers from document IDs --- .../current/serial_batch_identity.md | 25 ++ .../current/serial_batch_item_picker.png | Bin 0 -> 37110 bytes erpnext/controllers/queries.py | 17 +- erpnext/hooks.py | 1 + .../doctype/work_order/work_order.py | 60 +--- erpnext/patches.txt | 2 + .../patches/separate_serial_batch_identity.py | 10 + erpnext/public/js/erpnext.bundle.js | 2 + .../public/js/tests/barcode_scanner.test.cjs | 176 ++++++++++ .../js/tests/serial_batch_input.test.cjs | 256 +++++++++++++++ erpnext/public/js/utils/barcode_scanner.js | 160 +++++++-- .../public/js/utils/serial_batch_display.js | 19 ++ .../js/utils/serial_batch_inline_editor.js | 75 ++++- erpnext/public/js/utils/serial_batch_input.js | 165 ++++++++++ .../js/utils/serial_no_batch_selector.js | 7 +- .../page/point_of_sale/point_of_sale.py | 24 +- .../page/point_of_sale/pos_item_details.js | 1 + .../page/point_of_sale/pos_item_selector.js | 30 +- erpnext/stock/doctype/batch/batch.json | 13 +- erpnext/stock/doctype/batch/batch.py | 28 +- erpnext/stock/doctype/batch/test_batch.py | 17 +- .../delivery_note/test_delivery_note.py | 4 +- erpnext/stock/doctype/item/item.py | 22 +- erpnext/stock/doctype/item/test_item.py | 15 +- .../stock/doctype/pick_list/test_pick_list.py | 4 +- .../purchase_receipt/test_purchase_receipt.py | 28 +- .../serial_and_batch_bundle/inline_editor.py | 36 ++- .../serial_and_batch_bundle.py | 148 ++------- .../test_inline_editor.py | 79 +++-- .../test_serial_and_batch_bundle.py | 54 ++-- .../stock/doctype/serial_no/serial_no.json | 13 +- erpnext/stock/doctype/serial_no/serial_no.py | 21 +- .../stock/doctype/serial_no/test_serial_no.py | 14 +- .../doctype/stock_entry/test_stock_entry.py | 5 +- .../test_stock_reconciliation.py | 4 +- .../available_batch_report.py | 3 + .../available_serial_no.py | 2 + .../batch_item_expiry_status.py | 3 + .../test_batch_item_expiry_status.py | 7 +- .../batch_split_tree/batch_split_tree.py | 3 + .../batch_wise_balance_history.py | 2 + ...eue_vs_qty_after_transaction_comparison.py | 3 + .../incorrect_serial_no_valuation.py | 2 + .../negative_batch_report.py | 2 + .../serial_and_batch_summary.py | 72 ++--- .../serial_no_and_batch_traceability.js | 11 +- .../serial_no_and_batch_traceability.py | 3 + .../serial_no_ledger/serial_no_ledger.py | 2 + .../serial_no_service_contract_expiry.json | 2 +- .../serial_no_status/serial_no_status.json | 2 +- .../serial_no_warranty_expiry.json | 2 +- .../stock/report/stock_ledger/stock_ledger.py | 2 + .../stock_ledger_invariant_check.py | 2 + .../stock_ledger_variance.py | 2 + .../stock_qty_vs_batch_qty.py | 2 + erpnext/stock/serial_batch_bundle.py | 185 ++++------- erpnext/stock/serial_batch_display.py | 81 +++++ erpnext/stock/serial_batch_identity.py | 245 ++++++++++++++ erpnext/stock/tests/test_get_item_details.py | 26 ++ .../stock/tests/test_serial_batch_identity.py | 304 ++++++++++++++++++ erpnext/stock/tests/test_utils.py | 8 +- erpnext/stock/utils.py | 103 +++--- 62 files changed, 1964 insertions(+), 652 deletions(-) create mode 100644 erpnext/change_log/current/serial_batch_identity.md create mode 100644 erpnext/change_log/current/serial_batch_item_picker.png create mode 100644 erpnext/patches/separate_serial_batch_identity.py create mode 100644 erpnext/public/js/tests/barcode_scanner.test.cjs create mode 100644 erpnext/public/js/tests/serial_batch_input.test.cjs create mode 100644 erpnext/public/js/utils/serial_batch_display.js create mode 100644 erpnext/public/js/utils/serial_batch_input.js create mode 100644 erpnext/stock/serial_batch_display.py create mode 100644 erpnext/stock/serial_batch_identity.py create mode 100644 erpnext/stock/tests/test_serial_batch_identity.py 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 0000000000000000000000000000000000000000..e95649bd80156ce3b12a485c47db7fd00591ec65 GIT binary patch literal 37110 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�*`15qi@A;p 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 literal 0 HcmV?d00001 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 {}