refactor(stock): detect serial and batch input from field metadata

This commit is contained in:
Mihir Kandoi
2026-09-09 07:42:46 +05:30
parent 92b6d708d8
commit 1426a098f1
13 changed files with 112 additions and 83 deletions

View File

@@ -218,4 +218,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -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",

View File

@@ -137,4 +137,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -174,4 +174,4 @@
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -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;

View File

@@ -123,4 +123,4 @@
"sort_order": "ASC",
"states": [],
"track_changes": 1
}
}

View File

@@ -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))

View File

@@ -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")

View File

@@ -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()
)

View File

@@ -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()

View File

@@ -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):

View File

@@ -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,

View File

@@ -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