mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-04 18:23:05 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
1399
erpnext/public/js/utils/serial_batch_inline_editor.js
Normal file
1399
erpnext/public/js/utils/serial_batch_inline_editor.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
221
erpnext/stock/doctype/serial_and_batch_bundle/inline_editor.py
Normal file
221
erpnext/stock/doctype/serial_and_batch_bundle/inline_editor.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}]),
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user