mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 23:39:28 +00:00
refactor(stock): detect serial and batch input from field metadata
This commit is contained in:
@@ -218,4 +218,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -137,4 +137,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -174,4 +174,4 @@
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -123,4 +123,4 @@
|
||||
"sort_order": "ASC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user