feat: inline serial and batch entries editor (#57216)

* feat: inline serial and batch entries editor in Purchase Receipt

* feat: grid-style UX, deferred saves, scan and range options for inline serial batch editor

* feat: extend inline serial batch editor to all bundle doctypes with auto fetch

* fix: address review comments on inline serial batch editor

* fix: escape untrusted values in inline editor alerts

* fix: clear child bundle reference only when the row owns the bundle

* fix: keep inline serial batch editor disabled on existing sites via patch
This commit is contained in:
rohitwaghchaure
2026-07-19 19:21:09 +05:30
committed by GitHub
parent ddb094084e
commit 2eecdc48bf
21 changed files with 2238 additions and 16 deletions

View File

@@ -89,6 +89,8 @@
"item_tax_rate",
"actual_batch_qty",
"actual_qty",
"serial_batch_entries_section",
"serial_batch_entries_html",
"section_break_tlhi",
"serial_no",
"column_break_ciit",
@@ -859,6 +861,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_tlhi",
@@ -877,7 +888,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-06-08 20:00:00.000000",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice Item",

View File

@@ -75,6 +75,10 @@
"quality_inspection",
"rejected_warehouse",
"rejected_serial_and_batch_bundle",
"serial_batch_entries_section",
"serial_batch_entries_html",
"rejected_serial_batch_entries_section",
"rejected_serial_batch_entries_html",
"section_break_rqbe",
"serial_no",
"rejected_serial_no",
@@ -941,6 +945,24 @@
"label": "Use Serial No / Batch Fields",
"print_hide": 1
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"fieldname": "rejected_serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Rejected Serial / Batch Entries"
},
{
"fieldname": "rejected_serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"depends_on": "eval:!doc.is_fixed_asset && doc.use_serial_batch_fields === 1 && parent.update_stock === 1",
"fieldname": "section_break_rqbe",
@@ -1010,7 +1032,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-08 21:00:00.000000",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",

View File

@@ -94,6 +94,8 @@
"incoming_rate",
"item_tax_rate",
"actual_batch_qty",
"serial_batch_entries_section",
"serial_batch_entries_html",
"section_break_eoec",
"serial_no",
"column_break_ytgd",
@@ -954,6 +956,15 @@
"label": "Use Serial No / Batch Fields",
"print_hide": 1
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 1 && parent.update_stock === 1",
"fieldname": "section_break_eoec",
@@ -1055,7 +1066,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-08 20:00:00.000000",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -21,6 +21,8 @@
"serial_and_batch_bundle",
"use_serial_batch_fields",
"column_break_13",
"serial_batch_entries_section",
"serial_batch_entries_html",
"section_break_bfqc",
"serial_no",
"column_break_mbuv",
@@ -165,6 +167,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_bfqc",
@@ -185,7 +196,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-03-05 12:46:01.074742",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Capitalization Stock Item",
@@ -196,4 +207,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -13,7 +13,9 @@
"serial_no",
"column_break_xzfr",
"pick_serial_and_batch",
"serial_and_batch_bundle"
"serial_and_batch_bundle",
"serial_batch_entries_section",
"serial_batch_entries_html"
],
"fields": [
{
@@ -72,12 +74,21 @@
{
"fieldname": "column_break_xzfr",
"fieldtype": "Column Break"
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-06-27 14:52:56.311166",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Repair Consumed Item",

View File

@@ -500,3 +500,4 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
erpnext.patches.v16_0.access_control_for_project_users
erpnext.patches.v16_0.enable_book_stock_expense_gl_entries
execute:frappe.db.set_single_value("Stock Settings", "use_inline_serial_batch_editor", 0)

View File

@@ -6,6 +6,7 @@ import "./sms_manager";
import "./utils/party";
import "./controllers/stock_controller";
import "./utils/serial_no_batch_selector";
import "./utils/serial_batch_inline_editor";
import "./payment/payments";
import "./templates/visual_plant_floor_template.html";
import "./plant_floor_visual/visual_plant";

File diff suppressed because it is too large Load Diff

View File

@@ -86,6 +86,8 @@
"serial_and_batch_bundle",
"use_serial_batch_fields",
"column_break_eaoe",
"serial_batch_entries_section",
"serial_batch_entries_html",
"section_break_qyjv",
"serial_no",
"column_break_rxvc",
@@ -923,6 +925,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_qyjv",
@@ -971,7 +982,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-08 20:00:00.000000",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note Item",

View File

@@ -26,6 +26,8 @@
"use_serial_batch_fields",
"column_break_11",
"serial_and_batch_bundle",
"serial_batch_entries_section",
"serial_batch_entries_html",
"section_break_bgys",
"serial_no",
"column_break_qlha",
@@ -298,6 +300,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 1 && !['Sales Order', 'Quotation'].includes(parent.doctype)",
"fieldname": "section_break_bgys",
@@ -338,7 +349,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-08 15:00:00.000000",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packed Item",

View File

@@ -32,6 +32,8 @@
"serial_and_batch_bundle",
"use_serial_batch_fields",
"column_break_20",
"serial_batch_entries_section",
"serial_batch_entries_html",
"section_break_ecxc",
"serial_no",
"column_break_belw",
@@ -237,6 +239,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_ecxc",
@@ -296,7 +307,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-07-01 14:27:50.617011",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List Item",

View File

@@ -101,6 +101,10 @@
"col_break5",
"add_serial_batch_for_rejected_qty",
"rejected_serial_and_batch_bundle",
"serial_batch_entries_section",
"serial_batch_entries_html",
"rejected_serial_batch_entries_section",
"rejected_serial_batch_entries_html",
"section_break_3vxt",
"serial_no",
"rejected_serial_no",
@@ -1117,12 +1121,30 @@
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"fieldname": "rejected_serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Rejected Serial / Batch Entries"
},
{
"fieldname": "rejected_serial_batch_entries_html",
"fieldtype": "HTML"
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-08 21:00:00.000000",
"modified": "2026-07-16 15:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt Item",

View File

@@ -0,0 +1,221 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.query_builder.functions import Count, Sum
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,
)
SUPPORTED_VOUCHER_TYPES = frozenset(
[
"Purchase Receipt",
"Purchase Invoice",
"Sales Invoice",
"POS Invoice",
"Delivery Note",
"Stock Entry",
"Stock Reconciliation",
"Subcontracting Receipt",
"Pick List",
"Asset Capitalization",
"Asset Repair",
]
)
@frappe.whitelist()
def get_bundle_entries(bundle: str, start: int = 0, page_length: int = 50, search: str | None = None):
frappe.has_permission("Serial and Batch Bundle", "read", doc=bundle, throw=True)
page_length = min(cint(page_length) or 50, 500)
table = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(table)
.select(table.name, table.serial_no, table.batch_no, table.qty)
.where(table.parent == bundle)
.orderby(table.idx)
.limit(page_length)
.offset(cint(start))
)
if search:
search_term = f"%{search}%"
query = query.where((table.serial_no.like(search_term)) | (table.batch_no.like(search_term)))
entries = query.run(as_dict=True)
summary = get_bundle_summary(bundle)
summary["entries"] = entries
return summary
def get_bundle_summary(bundle):
table = frappe.qb.DocType("Serial and Batch Entry")
row = (
frappe.qb.from_(table)
.select(Count(table.name).as_("total_count"), Sum(table.qty).as_("total_qty"))
.where(table.parent == bundle)
).run(as_dict=True)[0]
return frappe._dict(
{
"bundle": bundle,
"total_count": cint(row.total_count),
"total_qty": abs(flt(row.total_qty)),
}
)
@frappe.whitelist()
def download_bundle_entries_csv(bundle: str):
from frappe.utils.csvutils import build_csv_response
frappe.has_permission("Serial and Batch Bundle", "read", doc=bundle, throw=True)
doc = frappe.get_doc("Serial and Batch Bundle", bundle)
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:
if item.has_serial_no and item.has_batch_no:
rows.append([entry.serial_no, entry.batch_no, abs(entry.qty)])
elif item.has_batch_no:
rows.append([entry.batch_no, abs(entry.qty)])
else:
rows.append([entry.serial_no])
build_csv_response(rows, f"{bundle}-entries")
def get_csv_columns(item):
if item.has_serial_no and item.has_batch_no:
return ["Serial No", "Batch No", "Quantity"]
if item.has_batch_no:
return ["Batch No", "Quantity"]
return ["Serial No"]
@frappe.whitelist(methods=["POST"])
def upsert_bundle_entries(
child_row: dict | str,
doc: dict | str,
entries: list | str | None = None,
deleted: list | str | None = None,
replace: int = 0,
):
child_row = parse_json(child_row)
doc = parse_json(doc)
entries = parse_json(entries) or []
deleted = parse_json(deleted) or []
validate_parent_document(child_row, doc)
bundle_field = (
"rejected_serial_and_batch_bundle" if child_row.get("is_rejected") else "serial_and_batch_bundle"
)
bundle_name = child_row.get(bundle_field)
if bundle_name and frappe.db.exists("Serial and Batch Bundle", bundle_name):
bundle = apply_incremental_changes(bundle_name, child_row, entries, deleted, cint(replace))
if not bundle.entries:
remove_empty_bundle(bundle, child_row, bundle_field)
return frappe._dict({"bundle": None, "total_count": 0, "total_qty": 0})
else:
if not 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)
bundle = create_serial_batch_no_ledgers(entries, child_row, doc)
return get_bundle_summary(bundle.name)
def validate_parent_document(child_row, doc):
if doc.get("doctype") not in SUPPORTED_VOUCHER_TYPES:
frappe.throw(
_("{0} is not supported for the inline Serial / Batch editor").format(doc.get("doctype"))
)
if child_row.get("parenttype") != doc.get("doctype"):
frappe.throw(_("The selected row does not belong to the {0}").format(doc.get("doctype")))
def remove_empty_bundle(bundle, child_row, bundle_field):
child_doctype, child_name = child_row.get("doctype"), child_row.get("name")
if (
child_name
and child_doctype
and frappe.get_meta(child_doctype).has_field(bundle_field)
and frappe.db.exists(child_doctype, {"name": child_name, bundle_field: bundle.name})
):
frappe.db.set_value(child_doctype, child_name, bundle_field, None)
bundle.delete(ignore_permissions=True)
def apply_incremental_changes(bundle_name, child_row, entries, deleted, replace=0):
frappe.has_permission("Serial and Batch Bundle", "write", doc=bundle_name, throw=True)
bundle = frappe.get_doc("Serial and Batch Bundle", bundle_name)
if bundle.docstatus == 1:
frappe.throw(
_("Serial and Batch Bundle {0} is submitted and its entries cannot be modified.").format(
frappe.bold(bundle_name)
)
)
sign = 1 if bundle.type_of_transaction == "Inward" else -1
if replace:
bundle.set("entries", [])
deleted = []
entries = [{key: value for key, value in row.items() if key != "name"} for row in entries]
if deleted:
bundle.entries = [d for d in bundle.entries if d.name not in deleted]
existing = {d.name: d for d in bundle.entries}
new_rows = [frappe._dict(row) for row in entries if not row.get("name")]
for row in entries:
if row.get("name") and row["name"] in existing:
entry = existing[row["name"]]
if row.get("qty") is not None:
entry.qty = (flt(row.get("qty")) or 1.0) * sign
if row.get("batch_no"):
entry.batch_no = row.get("batch_no")
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",
{
"qty": (flt(row.qty) or 1.0) * sign,
"warehouse": bundle.warehouse,
"batch_no": row.batch_no,
"serial_no": row.serial_no,
},
)
if not bundle.entries:
return bundle
bundle.save(ignore_permissions=True)
return bundle

View File

@@ -3011,6 +3011,9 @@ def get_auto_batch_nos(kwargs):
picked_batches,
)
if not kwargs.ignore_reserved_stock and not kwargs.for_stock_levels:
available_batches = remove_reservation_conflict_batches(available_batches, kwargs)
if kwargs.based_on == "Expiry":
available_batches = sorted(available_batches, key=lambda x: x.expiry_date or getdate("9999-12-31"))
@@ -3029,6 +3032,71 @@ def get_auto_batch_nos(kwargs):
return get_qty_based_available_batches(available_batches, qty)
def remove_reservation_conflict_batches(available_batches, kwargs):
if not available_batches or not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
return available_batches
conflicting_batches = get_cross_warehouse_reserved_batches(kwargs)
if not conflicting_batches:
return available_batches
return [d for d in available_batches if d.batch_no not in conflicting_batches]
def get_cross_warehouse_reserved_batches(kwargs) -> set:
from erpnext.stock.doctype.batch.batch import get_batch_qty
conflicting_batches = set()
for row in get_cross_warehouse_sre_details(kwargs):
if flt(row.outstanding_qty) <= 0:
continue
batch_qty = get_batch_qty(
row.batch_no,
row.warehouse,
posting_date=kwargs.get("posting_date"),
posting_time=kwargs.get("posting_time"),
consider_negative_batches=True,
)
if flt(batch_qty, 6) < flt(row.outstanding_qty, 6):
conflicting_batches.add(row.batch_no)
return conflicting_batches
def get_cross_warehouse_sre_details(kwargs):
sre = frappe.qb.DocType("Stock Reservation Entry")
sb_entry = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(sre)
.inner_join(sb_entry)
.on(sre.name == sb_entry.parent)
.select(
sb_entry.batch_no,
sre.warehouse,
Sum(sb_entry.qty - sb_entry.delivered_qty).as_("outstanding_qty"),
)
.where(
(sre.docstatus == 1)
& (sre.item_code == kwargs.item_code)
& (sre.delivered_qty < sre.reserved_qty)
& (sre.reservation_based_on == "Serial and Batch")
& (sb_entry.batch_no.isnotnull())
)
.groupby(sb_entry.batch_no, sre.warehouse)
)
if kwargs.get("company"):
query = query.where(sre.company == kwargs.get("company"))
if kwargs.warehouse:
warehouses = kwargs.warehouse if isinstance(kwargs.warehouse, list) else [kwargs.warehouse]
query = query.where(sre.warehouse.notin(warehouses))
return query.run(as_dict=True)
def get_batch_nos_from_sre(kwargs):
from frappe.query_builder.functions import Sum

View File

@@ -0,0 +1,346 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import json
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.tests.utils import ERPNextTestSuite
class TestSerialBatchInlineEditor(ERPNextTestSuite):
def make_draft_pr(self, item_code, qty=2):
return make_purchase_receipt(item_code=item_code, qty=qty, rate=100, do_not_submit=True)
def upsert(self, pr, entries=None, deleted=None, is_rejected=0, replace=0):
child_row = pr.items[0].as_dict()
child_row["is_rejected"] = is_rejected
return upsert_bundle_entries(
child_row=json.dumps(child_row, default=str),
doc=json.dumps(pr.as_dict(), default=str),
entries=json.dumps(entries or []),
deleted=json.dumps(deleted or []),
replace=replace,
)
def reload_row(self, pr):
pr.reload()
return pr.items[0]
def test_create_bundle_with_serials(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])
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))
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]}])
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]}])
second_entry_names = set(
frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")
)
self.assertEqual(summary.total_count, 3)
self.assertTrue(first_entry_names.issubset(second_entry_names))
def test_delete_entries(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])
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"
)
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]])
def test_batch_qty_update(self):
item = make_item(
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TSTBIE-.####",
}
).name
pr = self.make_draft_pr(item, qty=5)
batch = frappe.get_doc(doctype="Batch", item=item).insert()
summary = self.upsert(pr, entries=[{"batch_no": batch.name, "qty": 5}])
pr.items[0].serial_and_batch_bundle = summary.bundle
self.assertEqual(summary.total_qty, 5)
entry_name = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")[0]
summary = self.upsert(pr, entries=[{"name": entry_name, "qty": 8}])
self.assertEqual(summary.total_qty, 8)
self.assertEqual(summary.total_count, 1)
def test_update_serial_no_of_existing_entry(self):
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
pr = self.make_draft_pr(item, qty=1)
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}])
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.assertEqual(frappe.db.get_value("Serial and Batch Entry", entry_name, "serial_no"), new_serial)
self.assertTrue(frappe.db.exists("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
pr = self.make_draft_pr(item, qty=5)
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))
pr.items[0].serial_and_batch_bundle = summary.bundle
summary = self.upsert(pr, entries=[{"batch_no": batch2, "qty": 1}])
self.assertTrue(frappe.db.exists("Batch", batch2))
self.assertEqual(summary.total_qty, 5)
def test_update_batch_no_of_existing_entry(self):
item = make_item(
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TSTBIE-.####",
}
).name
pr = self.make_draft_pr(item, qty=5)
batch1 = frappe.get_doc(doctype="Batch", item=item).insert()
batch2 = frappe.get_doc(doctype="Batch", item=item).insert()
summary = self.upsert(pr, entries=[{"batch_no": batch1.name, "qty": 5}])
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, "batch_no": batch2.name}])
entry = frappe.db.get_value("Serial and Batch Entry", entry_name, ["batch_no", "qty"], as_dict=1)
self.assertEqual(entry.batch_no, batch2.name)
self.assertEqual(entry.qty, 5)
def test_delete_all_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])
bundle = summary.bundle
pr.items[0].serial_and_batch_bundle = bundle
pr.items[0].db_set("serial_and_batch_bundle", bundle)
to_delete = frappe.get_all("Serial and Batch Entry", {"parent": bundle}, pluck="name")
summary = self.upsert(pr, deleted=to_delete)
self.assertFalse(summary.bundle)
self.assertEqual(summary.total_count, 0)
self.assertFalse(frappe.db.exists("Serial and Batch Bundle", bundle))
self.assertFalse(
frappe.db.get_value("Purchase Receipt Item", pr.items[0].name, "serial_and_batch_bundle")
)
def test_remove_empty_bundle_ignores_spoofed_child_row(self):
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
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)}"}])
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_bundle = victim_summary.bundle
victim_pr.items[0].db_set("serial_and_batch_bundle", victim_bundle)
child_row = pr.items[0].as_dict()
child_row["is_rejected"] = 0
child_row["name"] = victim_pr.items[0].name
to_delete = frappe.get_all("Serial and Batch Entry", {"parent": bundle}, pluck="name")
upsert_bundle_entries(
child_row=json.dumps(child_row, default=str),
doc=json.dumps(pr.as_dict(), default=str),
deleted=json.dumps(to_delete),
)
self.assertFalse(frappe.db.exists("Serial and Batch Bundle", bundle))
self.assertEqual(
frappe.db.get_value("Purchase Receipt Item", victim_pr.items[0].name, "serial_and_batch_bundle"),
victim_bundle,
)
def test_pagination(self):
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
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])
page = get_bundle_entries(summary.bundle, start=0, page_length=2)
self.assertEqual(len(page["entries"]), 2)
self.assertEqual(page["total_count"], 5)
last_page = get_bundle_entries(summary.bundle, start=4, page_length=2)
self.assertEqual(len(last_page["entries"]), 1)
def test_search_entries(self):
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
pr = self.make_draft_pr(item)
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])
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}")
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)}"}])
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
)
self.assertNotEqual(accepted.bundle, rejected.bundle)
bundle = frappe.get_doc("Serial and Batch Bundle", rejected.bundle)
self.assertEqual(bundle.is_rejected, 1)
self.assertEqual(bundle.warehouse, "_Test Warehouse 1 - _TC")
def test_replace_entries(self):
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
pr = self.make_draft_pr(item, qty=3)
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])
pr.items[0].serial_and_batch_bundle = summary.bundle
summary = self.upsert(pr, entries=[{"serial_no": 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))
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])
bundle = summary.bundle
pr.items[0].serial_and_batch_bundle = bundle
summary = self.upsert(pr, entries=[], replace=1)
self.assertFalse(summary.bundle)
self.assertEqual(summary.total_count, 0)
self.assertFalse(frappe.db.exists("Serial and Batch Bundle", bundle))
def test_create_bundle_for_stock_entry(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
se = make_stock_entry(item_code=item, qty=2, to_warehouse="_Test Warehouse - _TC", do_not_submit=True)
child_row = se.items[0].as_dict()
child_row["is_rejected"] = 0
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)]),
deleted=json.dumps([]),
)
bundle = frappe.get_doc("Serial and Batch Bundle", summary.bundle)
self.assertEqual(bundle.voucher_type, "Stock Entry")
self.assertEqual(bundle.type_of_transaction, "Inward")
self.assertEqual(summary.total_qty, 2)
def test_upsert_requires_entries_for_new_bundle(self):
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
pr = self.make_draft_pr(item)
self.assertRaises(frappe.ValidationError, self.upsert, pr)
def test_upsert_rejects_mismatched_parenttype(self):
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
pr = self.make_draft_pr(item)
child_row = pr.items[0].as_dict()
child_row["is_rejected"] = 0
child_row["parenttype"] = "Task"
self.assertRaises(
frappe.ValidationError,
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"}]),
)
def test_upsert_rejects_unsupported_voucher_type(self):
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
pr = self.make_draft_pr(item)
child_row = pr.items[0].as_dict()
child_row["is_rejected"] = 0
child_row["parenttype"] = "Task"
doc = pr.as_dict()
doc["doctype"] = "Task"
self.assertRaises(
frappe.ValidationError,
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"}]),
)

View File

@@ -54,6 +54,8 @@
"use_serial_batch_fields",
"col_break4",
"serial_and_batch_bundle",
"serial_batch_entries_section",
"serial_batch_entries_html",
"section_break_rdtg",
"serial_no",
"column_break_prps",
@@ -615,6 +617,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_rdtg",
@@ -689,7 +700,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-03 12:11:53.714931",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",

View File

@@ -25,6 +25,8 @@
"column_break_11",
"serial_and_batch_bundle",
"current_serial_and_batch_bundle",
"serial_batch_entries_section",
"serial_batch_entries_html",
"section_break_lypk",
"serial_no",
"column_break_eefq",
@@ -246,6 +248,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_lypk",
@@ -266,7 +277,7 @@
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2025-11-20 15:27:13.868179",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reconciliation Item",

View File

@@ -47,6 +47,7 @@
"pick_serial_and_batch_based_on",
"allow_existing_serial_no",
"use_serial_batch_fields",
"use_inline_serial_batch_editor",
"disable_serial_no_and_batch_selector",
"section_break_gnhq",
"allow_negative_stock_for_batch",
@@ -595,6 +596,14 @@
{
"fieldname": "section_break_kcvr",
"fieldtype": "Section Break"
},
{
"default": "1",
"depends_on": "eval:!doc.use_serial_batch_fields",
"description": "Show an inline editable table for serial numbers / batches on the item row instead of the dialog",
"fieldname": "use_inline_serial_batch_editor",
"fieldtype": "Check",
"label": "Use Inline Serial / Batch Editor"
}
],
"icon": "icon-cog",
@@ -602,7 +611,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-26 10:00:00.000000",
"modified": "2026-07-16 17:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",

View File

@@ -66,6 +66,7 @@ class StockSettings(Document):
stock_uom: DF.Link | None
update_existing_price_list_rate: DF.Check
update_price_list_based_on: DF.Literal["Rate", "Price List Rate"]
use_inline_serial_batch_editor: DF.Check
use_naming_series: DF.Check
use_serial_batch_fields: DF.Check
validate_material_transfer_warehouses: DF.Check

View File

@@ -57,6 +57,10 @@
"col_break5",
"add_serial_batch_for_rejected_qty",
"rejected_serial_and_batch_bundle",
"serial_batch_entries_section",
"serial_batch_entries_html",
"rejected_serial_batch_entries_section",
"rejected_serial_batch_entries_html",
"section_break_jshh",
"serial_no",
"rejected_serial_no",
@@ -548,6 +552,24 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"fieldname": "rejected_serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Rejected Serial / Batch Entries"
},
{
"fieldname": "rejected_serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_jshh",
@@ -635,7 +657,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-01 10:00:00.000000",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Receipt Item",

View File

@@ -30,6 +30,8 @@
"use_serial_batch_fields",
"col_break4",
"subcontracting_order",
"serial_batch_entries_section",
"serial_batch_entries_html",
"section_break_zwnh",
"serial_no",
"column_break_qibi",
@@ -221,6 +223,15 @@
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
{
"fieldname": "serial_batch_entries_section",
"fieldtype": "Section Break",
"label": "Serial / Batch Entries"
},
{
"fieldname": "serial_batch_entries_html",
"fieldtype": "HTML"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 1",
"fieldname": "section_break_zwnh",
@@ -264,7 +275,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-05-27 12:33:58.772638",
"modified": "2026-07-18 10:00:00.000000",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Receipt Supplied Item",