From 1426a098f18b1daea82c91b67a1e6ce11d2e9e20 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 9 Sep 2026 07:42:46 +0530 Subject: [PATCH] refactor(stock): detect serial and batch input from field metadata --- .../purchase_receipt_item_supplied.json | 2 +- erpnext/hooks.py | 8 +--- .../maintenance_schedule_detail.json | 2 +- .../maintenance_schedule_item.json | 2 +- erpnext/public/js/utils/serial_batch_input.js | 9 ++-- .../installation_note_item.json | 2 +- erpnext/stock/serial_batch_display.py | 40 +++++----------- erpnext/stock/serial_batch_fields.py | 22 --------- erpnext/stock/serial_batch_import.py | 14 ++++-- erpnext/stock/serial_batch_input.py | 3 +- .../stock/tests/test_serial_batch_identity.py | 18 ++++--- .../stock/tests/test_serial_batch_input.py | 47 +++++++++++++++++-- erpnext/utilities/transaction_base.py | 26 ++++++++++ 13 files changed, 112 insertions(+), 83 deletions(-) delete mode 100644 erpnext/stock/serial_batch_fields.py diff --git a/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.json b/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.json index 90a48b12801..6ace8bddf39 100644 --- a/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.json +++ b/erpnext/buying/doctype/purchase_receipt_item_supplied/purchase_receipt_item_supplied.json @@ -218,4 +218,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 48d2c0ab733..272bf67957e 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -1,5 +1,3 @@ -from erpnext.stock.serial_batch_fields import SERIAL_TEXT_DOCTYPES - app_name = "erpnext" app_title = "ERPNext" app_publisher = "Frappe Technologies Pvt. Ltd." @@ -385,14 +383,12 @@ pre_submit_validation_doctypes = [ ] -extend_doctype_class.update( - {doctype: "erpnext.stock.serial_batch_display.SerialNumberDisplay" for doctype in SERIAL_TEXT_DOCTYPES} -) +pdf_body_html = "erpnext.stock.serial_batch_display.pdf_body_html" + extend_doctype_class["Data Import"] = "erpnext.stock.serial_batch_import.SerialBatchDataImport" 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/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json b/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json index c63ff65e81e..c625bc3312c 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json +++ b/erpnext/maintenance/doctype/maintenance_schedule_detail/maintenance_schedule_detail.json @@ -137,4 +137,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json b/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json index 75d63ab50a6..6a5da5e2646 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json +++ b/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json @@ -174,4 +174,4 @@ "sort_field": "creation", "sort_order": "DESC", "states": [] -} +} \ No newline at end of file diff --git a/erpnext/public/js/utils/serial_batch_input.js b/erpnext/public/js/utils/serial_batch_input.js index 0218ae8b3bb..ae7cbc06767 100644 --- a/erpnext/public/js/utils/serial_batch_input.js +++ b/erpnext/public/js/utils/serial_batch_input.js @@ -34,7 +34,10 @@ const with_serial_numbers = (BaseControl) => return super.parse_validate_and_set_in_model(value, event); } const context = this.number_context(); - if (context.row.parenttype && context.row.doctype !== "Stock Ledger Entry") { + if ( + context.row.parenttype && + frappe.meta.has_field(context.row.doctype, "serial_and_batch_bundle") + ) { const numbers = split_physical_numbers(value); await set_pending_number(context, this.df.fieldname, numbers.join("\n")); return; @@ -144,9 +147,7 @@ frappe.ui.form.ControlLink = class extends frappe.ui.form.ControlLink { doctype === "Batch" && this.df.fieldname === "batch_no" && row.parenttype && - ["Small Text", "Text", "Long Text"].includes( - frappe.meta.get_docfield(row.doctype, "serial_no")?.fieldtype - ) + frappe.meta.has_field(row.doctype, "serial_and_batch_bundle") ) { await set_pending_number({ frm, row }, "batch_no", (label ?? this.get_label_value()).trim()); return; diff --git a/erpnext/selling/doctype/installation_note_item/installation_note_item.json b/erpnext/selling/doctype/installation_note_item/installation_note_item.json index 0b3b07dd429..6438ca25c5f 100644 --- a/erpnext/selling/doctype/installation_note_item/installation_note_item.json +++ b/erpnext/selling/doctype/installation_note_item/installation_note_item.json @@ -123,4 +123,4 @@ "sort_order": "ASC", "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/stock/serial_batch_display.py b/erpnext/stock/serial_batch_display.py index 5bdc76502e6..96cae3e8537 100644 --- a/erpnext/stock/serial_batch_display.py +++ b/erpnext/stock/serial_batch_display.py @@ -1,11 +1,10 @@ """Display physical numbers while retaining document IDs for stock references.""" -from copy import copy +from copy import deepcopy from functools import wraps import frappe from frappe import _ -from frappe.model.base_document import BaseDocument from frappe.utils import escape_html from erpnext.stock.serial_batch_identity import SerialBatchIdentity @@ -61,7 +60,15 @@ def report_number_columns(columns, rows): return columns, rows -def before_print(doc, method=None, print_settings=None, **kwargs): +def pdf_body_html(template, args, **kwargs): + from frappe.utils.pdf import pdf_body_html as render_body + + print_doc = deepcopy(args["doc"]) + set_serial_number_labels(print_doc) + return render_body(template, {**args, "doc": print_doc}, **kwargs) + + +def set_serial_number_labels(doc): rows = [doc, *doc.get_all_children()] fields = ("serial_no", "rejected_serial_no", "current_serial_no") serial_rows = [ @@ -76,28 +83,5 @@ def before_print(doc, method=None, print_settings=None, **kwargs): if meta and meta.fieldtype in ("Small Text", "Text", "Long Text") and row.get(field): values.append((row, field, row.get(field).split("\n"))) labels = SerialBatchIdentity("Serial No").labels([name for _, _, names in values for name in names]) - for row, _field, _names in values: - row.__dict__["__serial_number_labels"] = labels - - -class SerialNumberDisplay: - def as_dict(self, *args, **kwargs): - doc = super().as_dict(*args, **kwargs) - if self.get("__serial_batch_input") and not kwargs.get("no_private_properties"): - doc["__serial_batch_input"] = self.get("__serial_batch_input").copy() - return doc - - def get_formatted(self, fieldname, *args, **kwargs): - field = self.meta.get_field(fieldname) - if ( - fieldname not in ("serial_no", "rejected_serial_no", "current_serial_no") - or not field - or field.fieldtype not in ("Small Text", "Text", "Long Text") - or not self.get(fieldname) - ): - return super().get_formatted(fieldname, *args, **kwargs) - names = self.get(fieldname).split("\n") - labels = self.get("__serial_number_labels") or SerialBatchIdentity("Serial No").labels(names) - print_row = copy(self) - print_row.set(fieldname, "\n".join(escape_html(labels.get(name, name)) for name in names)) - return BaseDocument.get_formatted(print_row, fieldname, *args, **kwargs) + for row, field, names in values: + row.set(field, "\n".join(escape_html(labels.get(name, name)) for name in names)) diff --git a/erpnext/stock/serial_batch_fields.py b/erpnext/stock/serial_batch_fields.py deleted file mode 100644 index 50927089c55..00000000000 --- a/erpnext/stock/serial_batch_fields.py +++ /dev/null @@ -1,22 +0,0 @@ -SERIAL_TEXT_DOCTYPES = ( - "Asset Capitalization Stock Item", - "Asset Repair Consumed Item", - "Delivery Note Item", - "Installation Note Item", - "Maintenance Schedule Detail", - "Maintenance Schedule Item", - "POS Invoice Item", - "Packed Item", - "Pick List Item", - "Purchase Invoice Item", - "Purchase Receipt Item", - "Purchase Receipt Item Supplied", - "Sales Invoice Item", - "Stock Entry Detail", - "Stock Ledger Entry", - "Stock Reconciliation Item", - "Subcontracting Receipt Item", - "Subcontracting Receipt Supplied Item", -) - -NUMBER_INPUT_DOCTYPES = tuple(doctype for doctype in SERIAL_TEXT_DOCTYPES if doctype != "Stock Ledger Entry") diff --git a/erpnext/stock/serial_batch_import.py b/erpnext/stock/serial_batch_import.py index 00d5f9653f9..55b715e4a1f 100644 --- a/erpnext/stock/serial_batch_import.py +++ b/erpnext/stock/serial_batch_import.py @@ -7,7 +7,6 @@ from frappe.utils.background_jobs import is_job_enqueued from frappe.utils.scheduler import is_scheduler_inactive from rq.timeouts import JobTimeoutException -from erpnext.stock.serial_batch_fields import NUMBER_INPUT_DOCTYPES from erpnext.stock.serial_batch_input import NUMBER_FIELDS @@ -64,7 +63,11 @@ class SerialBatchImporter(Importer): return for column in self.import_file.header.columns: df = column.df - if column.skip_import or not df or df.parent not in NUMBER_INPUT_DOCTYPES: + if ( + column.skip_import + or not df + or not frappe.get_meta(df.parent).has_field("serial_and_batch_bundle") + ): continue if df.fieldname == "batch_no": # Import cells contain physical numbers; validate their links after item resolution. @@ -81,7 +84,7 @@ class SerialBatchImporter(Importer): class SerialBatchImportRow(Row): def _parse_doc(self, doctype, columns, values, parent_doc=None, table_df=None): doc = super()._parse_doc(doctype, columns, values, parent_doc, table_df) - if doctype in NUMBER_INPUT_DOCTYPES: + if frappe.get_meta(doctype).has_field("serial_and_batch_bundle"): fields = [ column.df.fieldname for column, value in zip(columns, values, strict=True) @@ -93,4 +96,7 @@ class SerialBatchImportRow(Row): def has_number_inputs(doctype): - return any(df.options in NUMBER_INPUT_DOCTYPES for df in frappe.get_meta(doctype).get_table_fields()) + return any( + frappe.get_meta(df.options).has_field("serial_and_batch_bundle") + for df in frappe.get_meta(doctype).get_table_fields() + ) diff --git a/erpnext/stock/serial_batch_input.py b/erpnext/stock/serial_batch_input.py index 4d5d3621e69..3119b7c6af8 100644 --- a/erpnext/stock/serial_batch_input.py +++ b/erpnext/stock/serial_batch_input.py @@ -1,7 +1,6 @@ import frappe from frappe import _ -from erpnext.stock.serial_batch_fields import NUMBER_INPUT_DOCTYPES from erpnext.stock.serial_batch_identity import SerialBatchIdentity NUMBER_FIELDS = ("batch_no", "serial_no", "rejected_serial_no", "current_serial_no") @@ -11,7 +10,7 @@ def resolve_transaction_numbers(doc, method=None): if doc.docstatus == 2: return for row in [doc, *doc.get_all_children()]: - if row.doctype in NUMBER_INPUT_DOCTYPES: + if row.meta.has_field("serial_and_batch_bundle"): TransactionNumberInput(doc, row).resolve() diff --git a/erpnext/stock/tests/test_serial_batch_identity.py b/erpnext/stock/tests/test_serial_batch_identity.py index 467e7887070..f3ca5d06bb0 100644 --- a/erpnext/stock/tests/test_serial_batch_identity.py +++ b/erpnext/stock/tests/test_serial_batch_identity.py @@ -224,17 +224,11 @@ class TestSerialBatchIdentity(ERPNextTestSuite): 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, name) - self.assertIn("PRINT-123", print_doc.items[0].get_formatted("serial_no")) entry = frappe.get_doc("Serial and Batch Bundle", pr.items[0].serial_and_batch_bundle).entries[0] self.assertEqual(entry.serial_no, name) print_format = frappe.get_doc( @@ -247,10 +241,14 @@ class TestSerialBatchIdentity(ERPNextTestSuite): "html": "{{ doc.items[0].get_formatted('serial_no') }}", } ).insert() - printed = frappe.get_print("Purchase Receipt", pr.name, print_format=print_format.name, doc=print_doc) - self.assertEqual(print_doc.items[0].serial_no, name) - self.assertIn("PRINT-123", printed) - self.assertNotIn(name, printed) + for _ in range(2): + printed = frappe.get_print( + "Purchase Receipt", pr.name, print_format=print_format.name, doc=print_doc + ) + self.assertEqual(print_doc.items[0].serial_no, name) + self.assertEqual(print_doc.as_dict()["items"][0]["serial_no"], name) + self.assertIn("PRINT-123", printed) + self.assertNotIn(name, printed) pr.cancel() def test_number_search_returns_ids_and_physical_titles(self): diff --git a/erpnext/stock/tests/test_serial_batch_input.py b/erpnext/stock/tests/test_serial_batch_input.py index a0ae1329bb0..48b9af33c93 100644 --- a/erpnext/stock/tests/test_serial_batch_input.py +++ b/erpnext/stock/tests/test_serial_batch_input.py @@ -142,13 +142,54 @@ class TestSerialBatchInput(ERPNextTestSuite): receipt.insert() def test_no_extra_transaction_number_fields(self): - from erpnext.stock.serial_batch_fields import NUMBER_INPUT_DOCTYPES - - for doctype in NUMBER_INPUT_DOCTYPES: + for doctype in frappe.get_all( + "DocField", filters={"fieldname": "serial_and_batch_bundle"}, pluck="parent", distinct=True + ): meta = frappe.get_meta(doctype) for field in ("serial_number", "batch_number", "rejected_serial_number", "current_serial_number"): self.assertFalse(meta.has_field(field), (doctype, field)) + def test_input_requires_a_bundle_field(self): + from erpnext.stock.serial_batch_input import resolve_transaction_numbers + + receipt = self.make_receipt() + row = receipt.items[0] + row.serial_no = "Pending-Serial" + row.set("__serial_batch_input", ["serial_no"]) + with patch.object(row.meta, "has_field", return_value=False): + resolve_transaction_numbers(receipt) + self.assertEqual(row.serial_no, "Pending-Serial") + self.assertNotIn("__serial_batch_input", receipt.as_dict(no_private_properties=True)["items"][0]) + self.assertFalse(frappe.db.exists("Serial No", {"item_code": row.item_code})) + + def test_number_inputs_follow_child_field_metadata(self): + from erpnext.stock.serial_batch_import import has_number_inputs + from erpnext.stock.serial_batch_input import resolve_transaction_numbers + + doctype = "Installation Note Item" + meta = frappe.get_meta(doctype) + with patch.dict( + meta._fields, + {field: df for field, df in meta._fields.items() if field != "serial_and_batch_bundle"}, + clear=True, + ): + self.assertFalse(has_number_inputs("Installation Note")) + meta._fields["serial_and_batch_bundle"] = frappe._dict( + fieldname="serial_and_batch_bundle", fieldtype="Data" + ) + self.assertTrue(has_number_inputs("Installation Note")) + receipt = self.make_receipt(has_batch_no=0) + item = receipt.items[0].item_code + serial = SerialBatchIdentity("Serial No").resolve(item, ["Custom-Serial"], create=True)[0] + doc = frappe.get_doc(doctype="Installation Note", items=[{"item_code": item}]) + row = doc.items[0] + row.serial_no = "Custom-Serial" + row.set("__serial_batch_input", ["serial_no"]) + payload = frappe.parse_json(doc.as_json()) + self.assertEqual(payload["items"][0]["__serial_batch_input"], ["serial_no"]) + resolve_transaction_numbers(doc) + self.assertEqual(row.serial_no, serial) + def test_bundle_save_resolves_physical_entries(self): from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( add_serial_batch_ledgers, diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index a4f74b86944..b2efd6cf58d 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -18,6 +18,32 @@ class UOMMustBeIntegerError(frappe.ValidationError): class TransactionBase(StatusUpdater): + def as_dict( + self, + no_nulls=False, + no_default_fields=False, + convert_dates_to_str=False, + no_child_table_fields=False, + no_private_properties=False, + **kwargs, + ): + doc = super().as_dict( + no_nulls=no_nulls, + no_default_fields=no_default_fields, + convert_dates_to_str=convert_dates_to_str, + no_child_table_fields=no_child_table_fields, + no_private_properties=no_private_properties, + **kwargs, + ) + if not no_private_properties: + for df in self.meta.get_table_fields(): + for row, values in zip( + self.get(df.fieldname) or [], doc.get(df.fieldname) or [], strict=True + ): + if row.meta.has_field("serial_and_batch_bundle") and row.get("__serial_batch_input"): + values["__serial_batch_input"] = row.get("__serial_batch_input").copy() + return doc + def _validate_links(self): from erpnext.stock.serial_batch_input import resolve_transaction_numbers