Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 154 additions & 4 deletions erpnext/stock/doctype/item/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,18 @@ frappe.ui.form.on("Item", {
__("View")
);

frm.toggle_display(
["opening_stock"],
frappe.model.can_create("Stock Entry") && frappe.model.can_write("Stock Entry")
);
const can_create_stock_entry =
frappe.model.can_create("Stock Entry") && frappe.model.can_write("Stock Entry");

const has_existing_stock = frm.doc.__onload && frm.doc.__onload.stock_exists ? 1 : 0;

if (can_create_stock_entry && !has_existing_stock) {
frm.add_custom_button(
__("Set Opening Stock"),
() => erpnext.item.show_opening_stock_dialog(frm),
__("Actions")
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if (frm.doc.is_fixed_asset) {
Expand Down Expand Up @@ -703,6 +711,148 @@ $.extend(erpnext.item, {
);
},

show_opening_stock_dialog: function (frm) {
const companies = (frm.doc.item_defaults || []).map((d) => d.company).filter(Boolean);

if (!companies.length) {
frappe.msgprint({
title: __("No Company Found"),
message: __(
"Please add at least one row in Item Defaults with a Company before setting opening stock."
),
indicator: "orange",
});
return;
}
Comment on lines +714 to +726
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't make Item Defaults mandatory for this workflow.

The backend already falls back from Item Defaults to Stock Settings / "Stores", but the dialog exits immediately unless at least one item_defaults row exists. That means saved stock items without Item Defaults never get the new opening-stock flow even though the server path can resolve a warehouse for them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@erpnext/stock/doctype/item/item.js` around lines 708 - 720, The current
show_opening_stock_dialog function aborts when frm.doc.item_defaults is empty;
remove the early msgprint/return and instead allow the dialog flow to continue
even if companies array is empty so the server-side fallback (Stock
Settings/"Stores") can resolve a warehouse. Specifically, update
show_opening_stock_dialog to stop treating (frm.doc.item_defaults ||
[]).map(...) producing no companies as a hard error—either omit the
msgprint/return or replace it by continuing with companies = [] and ensuring the
subsequent dialog/request uses that empty companies value so backend resolution
can occur.


const get_warehouse_for_company = (company) => {
const row = (frm.doc.item_defaults || []).find((d) => d.company === company);
return (row && row.default_warehouse) || "";
};

const has_serial = cint(frm.doc.has_serial_no);
const has_batch = cint(frm.doc.has_batch_no);

const fields = [
{
label: __("Company"),
fieldname: "company",
fieldtype: "Select",
options: companies.join("\n"),
default: companies[0],
reqd: 1,
onchange: function () {
const warehouse = get_warehouse_for_company(dialog.get_value("company"));
dialog.set_value("warehouse", warehouse);
dialog.set_df_property(
"warehouse",
"description",
warehouse
? __("Default warehouse from Item Defaults.")
: __(
"No default warehouse set for this company. Entry will use Stock Settings default."
)
);
},
},
{
label: __("Default Warehouse"),
fieldname: "warehouse",
fieldtype: "Data",
read_only: 1,
description: __("Default warehouse from Item Defaults."),
},
{ fieldtype: "Column Break" },
{
label: __("Opening Stock"),
fieldname: "qty",
fieldtype: "Float",
default: frm.doc.opening_stock || 1,
reqd: 1,
},
{
label: __("Valuation Rate"),
fieldname: "valuation_rate",
fieldtype: "Currency",
default: frm.doc.valuation_rate || 0,
description: __("Leave as 0 to allow zero valuation rate."),
},
];

if (has_serial) {
fields.push(
{ fieldtype: "Section Break", label: __("Serial / Batch") },
{
label: __("Serial No Series"),
fieldname: "serial_no_series",
fieldtype: "Data",
default: frm.doc.serial_no_series || "",
reqd: 1,
description: __(
"Example: SN-.YYYY.-.#####. - One serial number will be created per unit of qty."
),
}
);
}

if (has_batch) {
if (!has_serial) {
fields.push({ fieldtype: "Section Break", label: __("Serial Nos / Batches") });
}
fields.push(
{
label: __("Automatically Create New Batch"),
fieldname: "create_new_batch",
fieldtype: "Check",
default: 1,
read_only: 1,
},
{
label: __("Batch Number Series"),
fieldname: "batch_number_series",
fieldtype: "Data",
default: frm.doc.batch_number_series || "",
reqd: 1,
description: __(
"Example: BATCH-.YYYY.-.#####. - A new batch will be auto-created from this series."
),
}
);
}

const dialog = new frappe.ui.Dialog({
title: __("Set Opening Stock"),
fields: fields,
primary_action_label: __("Create Stock Entry"),
primary_action: function (values) {
frappe.call({
method: "erpnext.stock.doctype.item.item.make_opening_stock_entry",
args: {
item_code: frm.doc.name,
company: values.company,
qty: values.qty,
valuation_rate: values.valuation_rate || 0,
warehouse: values.warehouse || null,
serial_no_series: values.serial_no_series || null,
create_new_batch: values.create_new_batch ? 1 : 0,
batch_number_series: values.batch_number_series || null,
Comment on lines +803 to +838
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid permanently enabling auto batch creation from this one-off dialog.

For batch items this always submits create_new_batch: 1, and make_opening_stock_entry() persists that back to the Item via persist_serial_batch_fields_for_opening_stock(...). Using a one-time opening-stock action will therefore silently change future batch behavior for the Item, which is a much bigger side effect than creating the initial stock entry.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@erpnext/stock/doctype/item/item.js` around lines 803 - 838, The dialog always
sets create_new_batch to 1 and passes it into make_opening_stock_entry which
causes persist_serial_batch_fields_for_opening_stock to update the Item record
permanently; change the dialog handling so it does not persist one-off choices:
pass create_new_batch and batch_number_series only as transient args for the
stock entry (avoid calling persist_*), or send a flag like
persist_serial_batch_fields=false to make_opening_stock_entry and ensure
make_opening_stock_entry (and persist_serial_batch_fields_for_opening_stock)
only persists when an explicit “save default” action is taken; update references
to create_new_batch, batch_number_series, make_opening_stock_entry and
persist_serial_batch_fields_for_opening_stock accordingly to prevent silent
Item-level changes from this dialog.

},
freeze: true,
freeze_message: __("Creating Opening Stock Entry..."),
callback: function (r) {
if (!r.exc && r.message) {
dialog.hide();
frm.reload_doc();
}
},
});
},
});

dialog.set_value("warehouse", get_warehouse_for_company(companies[0]));
dialog.show();
},

weight_to_validate: function (frm) {
if (frm.doc.weight_per_unit && !frm.doc.weight_uom) {
frappe.msgprint({
Expand Down
7 changes: 3 additions & 4 deletions erpnext/stock/doctype/item/item.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"item_name",
"item_group",
"stock_uom",
"image",
"column_break0",
"disabled",
"is_stock_item",
Expand All @@ -40,7 +41,6 @@
"over_delivery_receipt_allowance",
"column_break_wugd",
"over_billing_allowance",
"image",
"section_break_11",
"brand",
"description",
Expand Down Expand Up @@ -253,10 +253,9 @@
},
{
"bold": 1,
"depends_on": "eval:(doc.__islocal&&doc.is_stock_item && !doc.has_serial_no && !doc.has_batch_no)",
"description": "Used to create an opening Stock Entry with the Valuation Rate when the item is saved",
"fieldname": "opening_stock",
"fieldtype": "Float",
"hidden": 1,
Comment on lines 256 to +258
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep opening_stock available on new Items.

"hidden": 1 removes the inline field during initial Item creation too, so users lose the existing create-time opening-stock flow even though after_insert / set_opening_stock() still consumes opening_stock in erpnext/stock/doctype/item/item.py. This should be hidden only after the Item has been saved.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@erpnext/stock/doctype/item/item.json` around lines 256 - 258, The
opening_stock field in item.json is hidden unconditionally, which removes it
from the create form even though after_insert -> set_opening_stock() in
erpnext/stock/doctype/item/item.py still expects it; change the field to be
visible on new items and only hidden after the Item is saved—remove or replace
"hidden": 1 with logic that toggles visibility based on document state (e.g.,
use "read_only": 1 or a form script/depends_on that hides the field when docname
exists), and ensure references to opening_stock in set_opening_stock() and
after_insert continue to work.

"label": "Opening Stock"
},
{
Expand Down Expand Up @@ -1077,7 +1076,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2026-04-28 17:31:47.613279",
"modified": "2026-04-28 17:31:48.613279",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",
Expand Down
124 changes: 124 additions & 0 deletions erpnext/stock/doctype/item/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -1502,3 +1502,127 @@ def get_child_warehouses(warehouse):
from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses

return get_child_warehouses(warehouse)


@frappe.whitelist()
def make_opening_stock_entry(
item_code: str,
company: str,
qty: float,
valuation_rate: float,
warehouse: str | None = None,
serial_no_series: str | None = None,
create_new_batch: int = 0,
batch_number_series: str | None = None,
) -> str:
if not frappe.has_permission("Item", "write", item_code):
frappe.throw(_("Not permitted"), frappe.PermissionError)

item = frappe.get_doc("Item", item_code)

if not item.is_stock_item:
frappe.throw(_("Opening Stock can only be set for stock items."))

Comment on lines +1521 to +1525
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject "opening stock" once ledger activity already exists.

This endpoint trusts the client-side stock_exists gate, so a stale form or direct RPC can still create another "Opening Stock" material receipt after transactions already exist for the item. Please recheck item.stock_ledger_created() here before persisting fields or inserting the stock entry.

Suggested fix
 	item = frappe.get_doc("Item", item_code)

 	if not item.is_stock_item:
 		frappe.throw(_("Opening Stock can only be set for stock items."))
+
+	if item.stock_ledger_created():
+		frappe.throw(_("Opening stock can only be set before the first stock transaction."))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
item = frappe.get_doc("Item", item_code)
if not item.is_stock_item:
frappe.throw(_("Opening Stock can only be set for stock items."))
item = frappe.get_doc("Item", item_code)
if not item.is_stock_item:
frappe.throw(_("Opening Stock can only be set for stock items."))
if item.stock_ledger_created():
frappe.throw(_("Opening stock can only be set before the first stock transaction."))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@erpnext/stock/doctype/item/item.py` around lines 1489 - 1493, Before allowing
an Opening Stock entry to be created, re-check the item's ledger state by
calling item.stock_ledger_created() after loading the Item doc (the existing
code uses item = frappe.get_doc("Item", item_code) and currently only checks
item.is_stock_item); if item.stock_ledger_created() returns True, raise an
exception (frappe.throw) rejecting the Opening Stock creation with an
appropriate message so backend cannot be bypassed by stale client state or
direct RPC.

if flt(qty) <= 0:
frappe.throw(_("Quantity must be greater than zero."))

if flt(valuation_rate) < 0:
frappe.throw(_("Valuation Rate cannot be negative."))

if item.has_serial_no and not serial_no_series:
frappe.throw(_("Serial No Series is required for serialised items."))

if item.has_batch_no and cint(create_new_batch) and not batch_number_series:
frappe.throw(_("Batch Number Series is required when auto-creating a batch."))

if warehouse:
warehouse_company = frappe.db.get_value("Warehouse", warehouse, "company")
if warehouse_company != company:
frappe.throw(
_("Warehouse {0} does not belong to Company {1}.").format(
frappe.bold(warehouse), frappe.bold(company)
)
)

target_warehouse = get_default_warehouse_for_opening_stock(item, company, warehouse)

persist_serial_batch_fields_for_opening_stock(
item_code,
serial_no_series=serial_no_series,
create_new_batch=cint(create_new_batch),
batch_number_series=batch_number_series,
)

stock_entry = frappe.get_doc(
{
"doctype": "Stock Entry",
"stock_entry_type": "Material Receipt",
"company": company,
"items": [
{
"item_code": item_code,
"t_warehouse": target_warehouse,
"qty": flt(qty),
"basic_rate": flt(valuation_rate),
"allow_zero_valuation_rate": 1 if not flt(valuation_rate) else 0,
"use_serial_batch_fields": 1,
"serial_no_series": serial_no_series,
"batch_number_series": batch_number_series if cint(create_new_batch) else None,
}
],
}
)

stock_entry.insert()
stock_entry.submit()
stock_entry.add_comment("Comment", _("Opening Stock"))

frappe.msgprint(
_("Opening Stock entry created: {0}").format(get_link_to_form("Stock Entry", stock_entry.name)),
indicator="green",
alert=True,
)

return stock_entry.name


def get_default_warehouse_for_opening_stock(item, company: str, warehouse: str | None) -> str:
if warehouse:
return warehouse

for default in item.item_defaults:
if default.company == company and default.default_warehouse:
return default.default_warehouse

target = frappe.get_single_value("Stock Settings", "default_warehouse") or frappe.db.get_value(
"Warehouse", {"warehouse_name": _("Stores"), "company": company}
)

if not target:
frappe.throw(
_(
"No warehouse found for company {0}. Please set a Default Warehouse in Item Defaults or Stock Settings."
).format(frappe.bold(company))
)
Comment on lines +1597 to +1606
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep the Stock Settings fallback company-safe.

get_default_warehouse_for_opening_stock() now accepts Stock Settings.default_warehouse without checking its company. In a multi-company setup, that can point opening stock for Company B at Company A's warehouse and fail the transaction, whereas Item.set_opening_stock() previously fell back to that company's "Stores" warehouse in this case.

Suggested fix
-	target = frappe.get_single_value("Stock Settings", "default_warehouse") or frappe.db.get_value(
-		"Warehouse", {"warehouse_name": _("Stores"), "company": company}
-	)
+	target = frappe.get_single_value("Stock Settings", "default_warehouse")
+	if target and frappe.db.get_value("Warehouse", target, "company") != company:
+		target = None
+
+	if not target:
+		target = frappe.db.get_value("Warehouse", {"warehouse_name": _("Stores"), "company": company})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
target = frappe.get_single_value("Stock Settings", "default_warehouse") or frappe.db.get_value(
"Warehouse", {"warehouse_name": _("Stores"), "company": company}
)
if not target:
frappe.throw(
_(
"No warehouse found for company {0}. Please set a Default Warehouse in Item Defaults or Stock Settings."
).format(frappe.bold(company))
)
target = frappe.get_single_value("Stock Settings", "default_warehouse")
if target and frappe.db.get_value("Warehouse", target, "company") != company:
target = None
if not target:
target = frappe.db.get_value("Warehouse", {"warehouse_name": _("Stores"), "company": company})
if not target:
frappe.throw(
_(
"No warehouse found for company {0}. Please set a Default Warehouse in Item Defaults or Stock Settings."
).format(frappe.bold(company))
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@erpnext/stock/doctype/item/item.py` around lines 1565 - 1574, The Stock
Settings fallback used by get_default_warehouse_for_opening_stock() must be
company-safe: when reading frappe.get_single_value("Stock Settings",
"default_warehouse") check that the returned warehouse's company matches the
target company (use frappe.db.get_value("Warehouse", default_wh, "company") or
similar); if it belongs to a different company, ignore it and continue to the
existing fallback (frappe.db.get_value("Warehouse", {"warehouse_name":
_("Stores"), "company": company})). Ensure this logic is applied where
get_default_warehouse_for_opening_stock() is used (and mirrors the previous
Item.set_opening_stock() behaviour) so opening stock never resolves to a
warehouse in another company.


return target


def persist_serial_batch_fields_for_opening_stock(
item_code: str,
serial_no_series: str | None,
create_new_batch: int,
batch_number_series: str | None,
) -> None:
fields_to_update = {}

if serial_no_series:
fields_to_update["serial_no_series"] = serial_no_series

if create_new_batch:
fields_to_update["create_new_batch"] = 1
if batch_number_series:
fields_to_update["batch_number_series"] = batch_number_series

if fields_to_update:
frappe.db.set_value("Item", item_code, fields_to_update)
Loading