diff --git a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py
index 9dbd8f01b3b..f776994a29b 100644
--- a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py
+++ b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py
@@ -3,6 +3,7 @@
import frappe
from frappe import _
+from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt, get_link_to_form
import erpnext
@@ -130,6 +131,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
get_purchase_document_details,
)
+ from erpnext.stock.utils import get_valuation_method
doc = self.doc
tax_service = TaxService(doc)
@@ -329,20 +331,33 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
self.make_provisional_gl_entry(gl_entries, item)
if not doc.is_internal_transfer():
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": expense_account,
- "against": doc.supplier,
- "debit": base_amount,
- "debit_in_transaction_currency": amount,
- "cost_center": item.cost_center,
- "project": item.project or doc.project,
- },
- account_currency,
- item=item,
+ handled = False
+ if (
+ item.item_code
+ and item.item_code in stock_items
+ and item.get("purchase_receipt")
+ and not doc.is_return
+ and get_valuation_method(item.item_code, doc.company) == "Standard Cost"
+ ):
+ handled = self.make_standard_cost_srbnb_split(
+ gl_entries, item, expense_account, account_currency, base_amount
+ )
+
+ if not handled:
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": expense_account,
+ "against": doc.supplier,
+ "debit": base_amount,
+ "debit_in_transaction_currency": amount,
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ },
+ account_currency,
+ item=item,
+ )
)
- )
# check if the exchange rate has changed
if (
@@ -515,6 +530,107 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
},
)
+ def make_standard_cost_srbnb_split(
+ self, gl_entries, item, expense_account, account_currency, base_amount
+ ):
+ """For a Standard Cost item billed against a Purchase Receipt, clear SRBNB at the standard
+ value the receipt actually booked and post the (Net Amount - standard) difference to the
+ Purchase Price Variance account. Returns False (caller falls back) if the receipt value
+ can't be resolved."""
+ from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
+ get_purchase_price_variance_account,
+ )
+
+ doc = self.doc
+ precision = item.precision("base_net_amount")
+ standard_value = flt(self.get_pr_stock_value(item), precision)
+ if not standard_value:
+ return False
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": expense_account,
+ "against": doc.supplier,
+ "debit": standard_value,
+ "debit_in_transaction_currency": flt(standard_value / doc.conversion_rate, precision),
+ "remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ },
+ account_currency,
+ item=item,
+ )
+ )
+
+ variance = flt(base_amount - standard_value, precision)
+ if variance:
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": get_purchase_price_variance_account(item.item_code, doc.company),
+ "against": doc.supplier,
+ "debit": variance,
+ "debit_in_transaction_currency": flt(variance / doc.conversion_rate, precision),
+ "remarks": doc.get("remarks") or _("Purchase Price Variance"),
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ },
+ item=item,
+ )
+ )
+
+ return True
+
+ def get_pr_stock_value(self, item):
+ """Stock value (at standard) the linked Purchase Receipt booked for the quantity this invoice
+ row is billing.
+
+ Accepted and rejected stock for the same receipt row share `voucher_detail_no`, so the
+ warehouse filter is required: without it the accepted warehouse's SRBNB would be cleared at
+ accepted + rejected value and post the wrong Purchase Price Variance amount. The accepted
+ warehouse is read from the receipt row itself (not the invoice row, which may be unset on a
+ non-stock invoice).
+
+ The receipt's full accepted value is pro-rated to the invoiced quantity, so a partial bill
+ clears SRBNB (and posts PPV) for only the units it covers, not the whole receipt row."""
+ pr_detail = frappe.db.get_value(
+ "Purchase Receipt Item", item.pr_detail, ["warehouse", "stock_qty"], as_dict=True
+ )
+ if not pr_detail or not pr_detail.warehouse:
+ return 0.0
+
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+ result = (
+ frappe.qb.from_(sle)
+ .select(Sum(sle.stock_value_difference))
+ .where(
+ (sle.voucher_type == "Purchase Receipt")
+ & (sle.voucher_no == item.purchase_receipt)
+ & (sle.voucher_detail_no == item.pr_detail)
+ & (sle.warehouse == pr_detail.warehouse)
+ & (sle.is_cancelled == 0)
+ )
+ ).run()
+ accepted_value = flt(result[0][0]) if result and result[0][0] else 0.0
+ if not accepted_value or not flt(pr_detail.stock_qty):
+ return accepted_value
+
+ # Pro-rate to the quantity being billed by this invoice row (handles partial billing).
+ return accepted_value * flt(item.stock_qty) / flt(pr_detail.stock_qty)
+
+ def get_stock_variance_account(self, item):
+ """For Standard Cost items the purchase-price-vs-standard difference is a Purchase Price
+ Variance; for all other items it keeps the existing behaviour (default expense account)."""
+ from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
+ get_purchase_price_variance_account,
+ )
+ from erpnext.stock.utils import get_valuation_method
+
+ if item.item_code and get_valuation_method(item.item_code, self.doc.company) == "Standard Cost":
+ return get_purchase_price_variance_account(item.item_code, self.doc.company)
+ return self.doc.get_company_default("default_expense_account")
+
def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
doc = self.doc
net_amt_precision = item.precision("base_net_amount")
@@ -536,7 +652,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
)
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
- cost_of_goods_sold_account = doc.get_company_default("default_expense_account")
+ cost_of_goods_sold_account = self.get_stock_variance_account(item)
stock_adjustment_amt = stock_amount - warehouse_debit_amount
gl_entries.append(
@@ -561,7 +677,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
and warehouse_debit_amount
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
):
- cost_of_goods_sold_account = doc.get_company_default("default_expense_account")
+ cost_of_goods_sold_account = self.get_stock_variance_account(item)
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
stock_adjustment_amt = warehouse_debit_amount - stock_amount
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 5354c8c6f4e..0fe4ada4e5c 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -820,6 +820,8 @@ def create_item_wise_repost_entries(
):
"""Using a voucher create repost item valuation records for all item-warehouse pairs."""
+ from erpnext.stock.utils import get_valuation_method
+
stock_ledger_entries = get_items_to_be_repost(voucher_type, voucher_no)
distinct_item_warehouses = set()
@@ -831,6 +833,11 @@ def create_item_wise_repost_entries(
continue
distinct_item_warehouses.add(item_wh)
+ # Standard Cost items don't need a full repost: a backdated entry only shifts future balances
+ # (qty and value at the standard rate), which is done in place by update_qty_in_future_sle.
+ if get_valuation_method(sle.item_code) == "Standard Cost":
+ continue
+
repost_entry = frappe.new_doc("Repost Item Valuation")
repost_entry.based_on = "Item and Warehouse"
diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json
index 49f61238839..1244c72751d 100644
--- a/erpnext/setup/doctype/company/company.json
+++ b/erpnext/setup/doctype/company/company.json
@@ -129,6 +129,7 @@
"valuation_method",
"column_break_32",
"stock_adjustment_account",
+ "default_purchase_price_variance_account",
"stock_received_but_not_billed",
"stock_delivered_but_not_billed",
"disable_sdbnb_in_sr",
@@ -491,6 +492,15 @@
"no_copy": 1,
"options": "Account"
},
+ {
+ "description": "Used for items valued at Standard Cost: the difference between the purchase price and the standard rate is booked here.",
+ "fieldname": "default_purchase_price_variance_account",
+ "fieldtype": "Link",
+ "ignore_user_permissions": 1,
+ "label": "Default Purchase Price Variance Account",
+ "no_copy": 1,
+ "options": "Account"
+ },
{
"fieldname": "column_break_32",
"fieldtype": "Column Break"
@@ -1004,7 +1014,7 @@
"image_field": "company_logo",
"is_tree": 1,
"links": [],
- "modified": "2026-05-14 16:50:34.132345",
+ "modified": "2026-06-26 10:05:00.000000",
"modified_by": "Administrator",
"module": "Setup",
"name": "Company",
diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py
index b7393b49cba..2b3c40b22ca 100644
--- a/erpnext/stock/doctype/bin/bin.py
+++ b/erpnext/stock/doctype/bin/bin.py
@@ -277,19 +277,27 @@ def update_qty(bin_name, args):
- flt(bin_details.reserved_qty_for_production_plan)
)
- frappe.db.set_value(
- "Bin",
- bin_name,
- {
- "actual_qty": actual_qty,
- "ordered_qty": ordered_qty,
- "reserved_qty": reserved_qty,
- "indented_qty": indented_qty,
- "planned_qty": planned_qty,
- "projected_qty": projected_qty,
- },
- update_modified=True,
- )
+ bin_values = {
+ "actual_qty": actual_qty,
+ "ordered_qty": ordered_qty,
+ "reserved_qty": reserved_qty,
+ "indented_qty": indented_qty,
+ "planned_qty": planned_qty,
+ "projected_qty": projected_qty,
+ }
+
+ # Standard Cost items are not reposted on backdated entries, so the Bin's stock value is not
+ # refreshed by a repost. Keep it in step with the balance at the standard rate.
+ from erpnext.stock.utils import get_valuation_method
+
+ if get_valuation_method(args.get("item_code")) == "Standard Cost":
+ from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_item_standard_rate
+
+ bin_values["stock_value"] = flt(actual_qty) * flt(
+ get_item_standard_rate(args.get("item_code"), args.get("company"))
+ )
+
+ frappe.db.set_value("Bin", bin_name, bin_values, update_modified=True)
def get_actual_qty(item_code, warehouse):
diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json
index 8a458e8ea04..0f5840c3a0a 100644
--- a/erpnext/stock/doctype/item/item.json
+++ b/erpnext/stock/doctype/item/item.json
@@ -384,7 +384,7 @@
"fieldname": "valuation_method",
"fieldtype": "Select",
"label": "Valuation Method",
- "options": "\nFIFO\nMoving Average\nLIFO"
+ "options": "\nFIFO\nMoving Average\nLIFO\nStandard Cost"
},
{
"depends_on": "is_stock_item",
@@ -1090,7 +1090,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
- "modified": "2026-05-27 10:18:46.862670",
+ "modified": "2026-06-26 10:05:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",
diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py
index b3af09513cc..a26f58430bf 100644
--- a/erpnext/stock/doctype/item/item.py
+++ b/erpnext/stock/doctype/item/item.py
@@ -143,7 +143,7 @@ class Item(Document):
taxes: DF.Table[ItemTax]
total_projected_qty: DF.Float
uoms: DF.Table[UOMConversionDetail]
- valuation_method: DF.Literal["", "FIFO", "Moving Average", "LIFO"]
+ valuation_method: DF.Literal["", "FIFO", "Moving Average", "LIFO", "Standard Cost"]
valuation_rate: DF.Currency
variant_based_on: DF.Literal["Item Attribute", "Manufacturer"]
variant_of: DF.Link | None
@@ -239,6 +239,7 @@ class Item(Document):
self.validate_item_defaults()
self.validate_auto_reorder_enabled_in_stock_settings()
self.cant_change()
+ self.validate_standard_cost_change()
self.validate_item_tax_net_rate_range()
if not self.is_new():
@@ -1060,6 +1061,30 @@ class Item(Document):
for d in self.attributes:
d.variant_of = self.variant_of
+ def validate_standard_cost_change(self):
+ """Once stock exists, an item's valuation method cannot be switched to or from Standard
+ Cost — either change would leave existing stock valued on a basis the ledger never
+ recorded."""
+ if not self.is_standard_cost_valuation_change():
+ return
+
+ if self.stock_ledger_created():
+ frappe.throw(
+ _(
+ "Valuation Method cannot be changed to or from 'Standard Cost' for {0} because stock transactions already exist for it."
+ ).format(frappe.bold(self.name))
+ )
+
+ def is_standard_cost_valuation_change(self):
+ """True if this save switches the valuation method into or out of Standard Cost."""
+ if self.is_new() or not self.has_value_changed("valuation_method"):
+ return False
+
+ previous = self.get_doc_before_save()
+ was_standard = previous and previous.valuation_method == "Standard Cost"
+ is_standard = self.valuation_method == "Standard Cost"
+ return bool(was_standard or is_standard)
+
def cant_change(self):
if self.is_new():
return
diff --git a/erpnext/stock/doctype/item_default/item_default.json b/erpnext/stock/doctype/item_default/item_default.json
index da74d45eeb6..9b753557dd8 100644
--- a/erpnext/stock/doctype/item_default/item_default.json
+++ b/erpnext/stock/doctype/item_default/item_default.json
@@ -34,6 +34,7 @@
"default_provisional_account",
"purchase_expense_account",
"purchase_expense_contra_account",
+ "purchase_price_variance_account",
"selling_defaults",
"column_break_sales",
"vf_selling_cost_center",
@@ -189,6 +190,14 @@
"options": "Account",
"show_description_on_click": 1
},
+ {
+ "description": "For Standard Cost items: the purchase price vs standard rate difference is booked here. Falls back to the Company's Default Purchase Price Variance Account.",
+ "fieldname": "purchase_price_variance_account",
+ "fieldtype": "Link",
+ "label": "Purchase Price Variance Account",
+ "options": "Account",
+ "show_description_on_click": 1
+ },
{
"fieldname": "column_break_purchase",
"fieldtype": "Column Break"
@@ -356,7 +365,7 @@
],
"istable": 1,
"links": [],
- "modified": "2026-06-03 17:25:35.982082",
+ "modified": "2026-06-26 10:05:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item Default",
diff --git a/erpnext/stock/doctype/item_standard_cost/__init__.py b/erpnext/stock/doctype/item_standard_cost/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/stock/doctype/item_standard_cost/item_standard_cost.js b/erpnext/stock/doctype/item_standard_cost/item_standard_cost.js
new file mode 100644
index 00000000000..f867de3ab67
--- /dev/null
+++ b/erpnext/stock/doctype/item_standard_cost/item_standard_cost.js
@@ -0,0 +1,16 @@
+// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Item Standard Cost", {
+ setup(frm) {
+ // Only allow items whose effective valuation method is "Standard Cost".
+ frm.set_query("item_code", () => {
+ return {
+ query: "erpnext.stock.doctype.item_standard_cost.item_standard_cost.get_standard_cost_items",
+ filters: {
+ company: frm.doc.company,
+ },
+ };
+ });
+ },
+});
diff --git a/erpnext/stock/doctype/item_standard_cost/item_standard_cost.json b/erpnext/stock/doctype/item_standard_cost/item_standard_cost.json
new file mode 100644
index 00000000000..7a0e8ab85c9
--- /dev/null
+++ b/erpnext/stock/doctype/item_standard_cost/item_standard_cost.json
@@ -0,0 +1,138 @@
+{
+ "actions": [],
+ "allow_import": 1,
+ "autoname": "naming_series:",
+ "creation": "2026-06-26 11:00:00.000000",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "naming_series",
+ "item_code",
+ "company",
+ "column_break_main",
+ "standard_rate",
+ "effective_date",
+ "revaluation_section",
+ "revaluation_entry",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "default": "ISC-.YYYY.-",
+ "fieldname": "naming_series",
+ "fieldtype": "Select",
+ "label": "Series",
+ "options": "ISC-.YYYY.-",
+ "reqd": 1,
+ "set_only_once": 1
+ },
+ {
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Item",
+ "options": "Item",
+ "reqd": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Company",
+ "options": "Company",
+ "reqd": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "column_break_main",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "standard_rate",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Standard Valuation Rate",
+ "options": "Company:company:default_currency",
+ "reqd": 1
+ },
+ {
+ "default": "Today",
+ "fieldname": "effective_date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Effective Date",
+ "reqd": 1
+ },
+ {
+ "fieldname": "revaluation_section",
+ "fieldtype": "Section Break",
+ "label": "Revaluation"
+ },
+ {
+ "description": "Stock Reconciliation auto-created to revalue on-hand stock to the new standard rate.",
+ "fieldname": "revaluation_entry",
+ "fieldtype": "Link",
+ "label": "Revaluation Entry",
+ "no_copy": 1,
+ "options": "Stock Reconciliation",
+ "read_only": 1
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Item Standard Cost",
+ "print_hide": 1,
+ "read_only": 1,
+ "search_index": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2026-06-26 11:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Stock",
+ "name": "Item Standard Cost",
+ "naming_rule": "By \"Naming Series\" field",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Stock Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
+}
diff --git a/erpnext/stock/doctype/item_standard_cost/item_standard_cost.py b/erpnext/stock/doctype/item_standard_cost/item_standard_cost.py
new file mode 100644
index 00000000000..09a00c886d6
--- /dev/null
+++ b/erpnext/stock/doctype/item_standard_cost/item_standard_cost.py
@@ -0,0 +1,299 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.query_builder.functions import Max
+from frappe.utils import flt, get_datetime, get_link_to_form, getdate, nowtime, today
+from frappe.utils.caching import request_cache
+
+from erpnext.stock.utils import get_valuation_method
+
+
+class ItemStandardCost(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ amended_from: DF.Link | None
+ company: DF.Link
+ effective_date: DF.Date
+ item_code: DF.Link
+ naming_series: DF.Literal["ISC-.YYYY.-"]
+ revaluation_entry: DF.Link | None
+ standard_rate: DF.Currency
+ # end: auto-generated types
+
+ def validate(self):
+ self.validate_item()
+ self.validate_effective_date()
+ self.validate_rate()
+
+ def validate_item(self):
+ if not frappe.get_cached_value("Item", self.item_code, "is_stock_item"):
+ frappe.throw(_("{0} is not a stock item.").format(frappe.bold(self.item_code)))
+
+ if get_valuation_method(self.item_code, self.company) != "Standard Cost":
+ frappe.throw(
+ _("Valuation Method of Item {0} must be set to 'Standard Cost'.").format(
+ get_link_to_form("Item", self.item_code)
+ )
+ )
+
+ def validate_effective_date(self):
+ # Standard cost is set "as of now"; future-dating would leave a gap where new receipts
+ # are valued at a rate that is not yet effective.
+ if getdate(self.effective_date) > getdate(today()):
+ frappe.throw(_("Effective Date cannot be a future date."))
+
+ # Effective dates must be strictly increasing so the rate history can be read by date.
+ last = self.get_last_standard_cost()
+ if last and getdate(self.effective_date) <= getdate(last.effective_date):
+ frappe.throw(
+ _("Effective Date must be after {0} (the last Standard Cost {1}).").format(
+ frappe.bold(frappe.format(last.effective_date, "Date")),
+ get_link_to_form("Item Standard Cost", last.name),
+ )
+ )
+
+ def validate_rate(self):
+ if flt(self.standard_rate) <= 0:
+ frappe.throw(_("Standard Valuation Rate must be greater than zero."))
+
+ if self.get_last_standard_cost() is None:
+ # First-ever rate for this item+company: only allowed when no stock movement exists,
+ # so the item starts its life under Standard Cost (no historical revaluation needed).
+ if self.has_any_sle():
+ frappe.throw(
+ _(
+ "Standard Cost can only be set up for {0} in {1} before any stock transaction exists."
+ ).format(get_link_to_form("Item", self.item_code), frappe.bold(self.company))
+ )
+ return
+
+ # R1: a rate change must be effective on/after the latest stock activity, so the
+ # revaluation entry it creates never sits behind existing transactions.
+ last_sle_date = self.get_last_sle_date()
+ if last_sle_date and getdate(self.effective_date) < getdate(last_sle_date):
+ frappe.throw(
+ _("Effective Date cannot be before the last stock transaction date {0}.").format(
+ frappe.bold(frappe.format(last_sle_date, "Date"))
+ )
+ )
+
+ def on_submit(self):
+ # This record is now the effective rate. Drop any request-cached lookup that may have read the
+ # previous (or missing) rate earlier in the request, so the revaluation below — and anything
+ # else in this request — reads the newly submitted rate.
+ clear_item_standard_rate_cache()
+ self.create_revaluation_entry()
+
+ def before_cancel(self):
+ frappe.throw(
+ _("Item Standard Cost cannot be cancelled. Submit a new record to change the standard rate.")
+ )
+
+ def create_revaluation_entry(self):
+ """Revalue on-hand stock to the new standard rate via a Stock Reconciliation.
+
+ Submitted atomically: if the reconciliation cannot be submitted (closed period, frozen
+ accounts, etc.) the exception propagates and this submission is rolled back."""
+ balances = self.get_warehouse_wise_balance()
+ if not balances:
+ return
+
+ reco = frappe.new_doc("Stock Reconciliation")
+ reco.company = self.company
+ reco.purpose = "Stock Reconciliation"
+ reco.posting_date = self.effective_date
+ reco.posting_time = self.get_revaluation_posting_time()
+ reco.set_posting_time = 1
+ for row in balances:
+ reco.append(
+ "items",
+ {
+ "item_code": self.item_code,
+ "warehouse": row.warehouse,
+ "qty": row.actual_qty,
+ "valuation_rate": self.standard_rate,
+ },
+ )
+
+ reco.flags.via_item_standard_cost = True
+ reco.insert()
+ reco.submit()
+
+ self.db_set("revaluation_entry", reco.name)
+
+ def get_revaluation_posting_time(self):
+ """Post the revaluation after the day's last stock movement.
+
+ The reconciliation asserts the current on-hand quantity (Bin.actual_qty). If it were posted
+ before later same-day movements, it would backdate that quantity ahead of them and corrupt the
+ qty/value timeline. Using the time of the last SLE on the effective date (the reconciliation
+ sorts after it on creation) keeps the snapshot at the correct point; if there is no movement
+ that day, the current time is safe since no later movement can exist."""
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+ result = (
+ frappe.qb.from_(sle)
+ .select(Max(sle.posting_datetime))
+ .where(
+ (sle.item_code == self.item_code)
+ & (sle.company == self.company)
+ & (sle.is_cancelled == 0)
+ & (sle.posting_date == getdate(self.effective_date))
+ )
+ ).run()
+
+ last_datetime = result[0][0] if result and result[0][0] else None
+ # Keep microsecond precision: posting_datetime is compared at microsecond granularity, so a
+ # truncated time would sort the reco before a same-second movement. Matching the exact time
+ # lets the later creation order the reco after it.
+ return get_datetime(last_datetime).strftime("%H:%M:%S.%f") if last_datetime else nowtime()
+
+ def get_warehouse_wise_balance(self):
+ bin_table = frappe.qb.DocType("Bin")
+ warehouse = frappe.qb.DocType("Warehouse")
+ return (
+ frappe.qb.from_(bin_table)
+ .inner_join(warehouse)
+ .on(bin_table.warehouse == warehouse.name)
+ .select(bin_table.warehouse, bin_table.actual_qty)
+ .where(
+ (bin_table.item_code == self.item_code)
+ & (warehouse.company == self.company)
+ & (bin_table.actual_qty != 0)
+ )
+ ).run(as_dict=True)
+
+ def get_last_standard_cost(self):
+ records = frappe.get_all(
+ "Item Standard Cost",
+ filters={
+ "item_code": self.item_code,
+ "company": self.company,
+ "docstatus": 1,
+ "name": ("!=", self.name),
+ },
+ fields=["name", "effective_date"],
+ order_by="effective_date desc, creation desc",
+ limit=1,
+ )
+ return records[0] if records else None
+
+ def get_last_sle_date(self):
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+ result = (
+ frappe.qb.from_(sle)
+ .select(Max(sle.posting_date))
+ .where(
+ (sle.item_code == self.item_code) & (sle.company == self.company) & (sle.is_cancelled == 0)
+ )
+ ).run()
+ return result[0][0] if result and result[0][0] else None
+
+ def has_any_sle(self):
+ return bool(
+ frappe.db.exists(
+ "Stock Ledger Entry",
+ {"item_code": self.item_code, "company": self.company, "is_cancelled": 0},
+ )
+ )
+
+
+@request_cache
+def get_item_standard_rate(item_code, company, posting_date=None):
+ """Return the standard valuation rate effective for `item_code` in `company` as of
+ `posting_date` (defaults to today) — i.e. the latest submitted Item Standard Cost whose
+ effective date is on or before the posting date."""
+ posting_date = posting_date or today()
+
+ rate = frappe.get_all(
+ "Item Standard Cost",
+ filters={
+ "item_code": item_code,
+ "company": company,
+ "docstatus": 1,
+ "effective_date": ("<=", getdate(posting_date)),
+ },
+ fields=["standard_rate"],
+ order_by="effective_date desc, creation desc",
+ limit=1,
+ pluck="standard_rate",
+ )
+
+ return flt(rate[0]) if rate else None
+
+
+def clear_item_standard_rate_cache():
+ """Drop the request-cached results of `get_item_standard_rate` so reads after a new Item Standard
+ Cost is submitted see the fresh rate instead of a value cached earlier in the same request."""
+ cache = getattr(frappe.local, "request_cache", None)
+ if cache:
+ cache.pop(get_item_standard_rate.__wrapped__, None)
+
+
+def get_purchase_price_variance_account(item_code, company):
+ """Resolve the Purchase Price Variance account for a Standard Cost item: the per-company
+ Item Default override if set, otherwise the Company default."""
+ account = frappe.db.get_value(
+ "Item Default",
+ {"parent": item_code, "company": company},
+ "purchase_price_variance_account",
+ )
+
+ if not account:
+ account = frappe.get_cached_value("Company", company, "default_purchase_price_variance_account")
+
+ if not account:
+ frappe.throw(
+ _(
+ "Please set a Purchase Price Variance Account for Item {0} or a Default Purchase Price Variance Account in Company {1}."
+ ).format(get_link_to_form("Item", item_code), frappe.bold(company))
+ )
+
+ return account
+
+
+@frappe.whitelist()
+@frappe.validate_and_sanitize_search_inputs
+def get_standard_cost_items(
+ doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None
+):
+ """Link-field query for Item Standard Cost: only items whose effective valuation method is
+ 'Standard Cost' — i.e. the item is explicitly Standard Cost, or it has no valuation method of its
+ own and the applicable default (Company, else Stock Settings) is Standard Cost. This mirrors
+ get_valuation_method, so every shown item also passes validate_item."""
+ company = (filters or {}).get("company")
+ if company:
+ default_method = frappe.get_cached_value("Company", company, "valuation_method")
+ else:
+ default_method = frappe.db.get_single_value("Stock Settings", "valuation_method")
+
+ if default_method == "Standard Cost":
+ # Items with no method of their own inherit the Standard Cost default.
+ valuation_condition = "and ifnull(item.valuation_method, '') in ('', 'Standard Cost')"
+ else:
+ valuation_condition = "and item.valuation_method = 'Standard Cost'"
+
+ return frappe.db.sql( # nosemgrep
+ f"""
+ select item.name, item.item_name
+ from `tabItem` item
+ where item.is_stock_item = 1
+ and item.disabled = 0
+ and item.has_variants = 0
+ {valuation_condition}
+ and ({searchfield} like %(txt)s or item.item_name like %(txt)s)
+ order by
+ (case when item.name like %(txt)s then 0 else 1 end),
+ item.name
+ limit %(page_len)s offset %(start)s
+ """,
+ {"txt": f"%{txt}%", "start": start, "page_len": page_len},
+ )
diff --git a/erpnext/stock/doctype/item_standard_cost/test_item_standard_cost.py b/erpnext/stock/doctype/item_standard_cost/test_item_standard_cost.py
new file mode 100644
index 00000000000..c695694eeca
--- /dev/null
+++ b/erpnext/stock/doctype/item_standard_cost/test_item_standard_cost.py
@@ -0,0 +1,487 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.utils import add_days, flt, today
+
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+from erpnext.tests.utils import ERPNextTestSuite
+
+TEST_COMPANY = "_Test Company"
+TEST_WAREHOUSE = "_Test Warehouse - _TC"
+
+# Perpetual-inventory company, needed to assert stock GL entries.
+PI_COMPANY = "_Test Company with perpetual inventory"
+PI_STORES = "Stores - TCP1"
+PI_FG = "Finished Goods - TCP1"
+
+
+def create_standard_cost_item(**properties):
+ props = {"valuation_method": "Standard Cost", "is_stock_item": 1, "is_purchase_item": 1}
+ props.update(properties)
+ return make_item(properties=props)
+
+
+def create_item_standard_cost(item_code, rate, company=TEST_COMPANY, effective_date=None, submit=True):
+ doc = frappe.new_doc("Item Standard Cost")
+ doc.item_code = item_code
+ doc.company = company
+ doc.standard_rate = rate
+ doc.effective_date = effective_date or today()
+ doc.insert()
+ if submit:
+ doc.submit()
+ return doc
+
+
+def ensure_ppv_account(company):
+ """Ensure `company` has a Default Purchase Price Variance Account so receipts/invoices of
+ Standard Cost items can book the receipt-rate-vs-standard difference."""
+ account = frappe.get_cached_value("Company", company, "default_purchase_price_variance_account")
+ if account:
+ return account
+
+ from erpnext.accounts.doctype.account.test_account import create_account
+
+ # Place it under the same group as the company's default expense account.
+ expense_account = frappe.get_cached_value("Company", company, "default_expense_account")
+ parent_account = frappe.db.get_value("Account", expense_account, "parent_account")
+ account = create_account(
+ account_name="Purchase Price Variance",
+ account_type="Expense Account",
+ parent_account=parent_account,
+ company=company,
+ account_currency=frappe.get_cached_value("Company", company, "default_currency"),
+ )
+ frappe.db.set_value("Company", company, "default_purchase_price_variance_account", account)
+ return account
+
+
+class TestItemStandardCost(ERPNextTestSuite):
+ def setUp(self):
+ ensure_ppv_account(TEST_COMPANY)
+ ensure_ppv_account(PI_COMPANY)
+
+ def test_only_for_standard_cost_items(self):
+ item = make_item(properties={"valuation_method": "FIFO", "is_stock_item": 1})
+ isc = frappe.new_doc("Item Standard Cost")
+ isc.item_code = item.name
+ isc.company = TEST_COMPANY
+ isc.standard_rate = 100
+ self.assertRaises(frappe.ValidationError, isc.insert)
+
+ def test_item_link_query_lists_only_standard_cost_items(self):
+ from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_standard_cost_items
+
+ sc_item = create_standard_cost_item().name
+ fifo_item = make_item(properties={"valuation_method": "FIFO", "is_stock_item": 1}).name
+
+ def listed(item_code):
+ rows = get_standard_cost_items("Item", item_code, "name", 0, 20, {"company": TEST_COMPANY})
+ return item_code in [row[0] for row in rows]
+
+ self.assertTrue(listed(sc_item))
+ self.assertFalse(listed(fifo_item))
+
+ def test_rate_must_be_positive(self):
+ item = create_standard_cost_item()
+ isc = frappe.new_doc("Item Standard Cost")
+ isc.item_code = item.name
+ isc.company = TEST_COMPANY
+ isc.standard_rate = 0
+ self.assertRaises(frappe.ValidationError, isc.insert)
+
+ def test_future_effective_date_blocked(self):
+ item = create_standard_cost_item()
+ isc = frappe.new_doc("Item Standard Cost")
+ isc.item_code = item.name
+ isc.company = TEST_COMPANY
+ isc.standard_rate = 100
+ isc.effective_date = add_days(today(), 5)
+ self.assertRaises(frappe.ValidationError, isc.insert)
+
+ def test_first_record_requires_no_stock_ledger_entry(self):
+ # An item that already has stock movement cannot be moved onto Standard Cost retroactively.
+ item = make_item(properties={"valuation_method": "FIFO", "is_stock_item": 1})
+ make_stock_entry(item_code=item.name, target=TEST_WAREHOUSE, qty=5, basic_rate=100)
+
+ # Force the method at the db level (the Item-level guard would otherwise block enabling
+ # Standard Cost while stock exists) and drop the cached valuation method.
+ frappe.db.set_value("Item", item.name, "valuation_method", "Standard Cost")
+ frappe.local.request_cache.clear()
+
+ isc = frappe.new_doc("Item Standard Cost")
+ isc.item_code = item.name
+ isc.company = TEST_COMPANY
+ isc.standard_rate = 100
+ self.assertRaises(frappe.ValidationError, isc.insert)
+
+ def test_receipt_valued_at_standard(self):
+ item = create_standard_cost_item()
+ create_item_standard_cost(item.name, rate=100)
+
+ # Receive at a different (billed) rate; the ledger must still value at the standard 100.
+ se = make_stock_entry(item_code=item.name, target=TEST_WAREHOUSE, qty=10, basic_rate=150)
+
+ sle = frappe.get_all(
+ "Stock Ledger Entry",
+ filters={"voucher_no": se.name, "is_cancelled": 0},
+ fields=["valuation_rate", "stock_value", "incoming_rate"],
+ )[0]
+ self.assertEqual(flt(sle.valuation_rate), 100)
+ self.assertEqual(flt(sle.stock_value), 1000)
+ self.assertEqual(flt(sle.incoming_rate), 100)
+
+ def test_rate_change_revalues_on_hand_stock(self):
+ # Effective dates must strictly increase, so stage the rate change on a later date.
+ item = create_standard_cost_item()
+ create_item_standard_cost(item.name, rate=100, effective_date=add_days(today(), -10))
+ make_stock_entry(
+ item_code=item.name,
+ target=TEST_WAREHOUSE,
+ qty=10,
+ basic_rate=100,
+ posting_date=add_days(today(), -5),
+ )
+
+ isc = create_item_standard_cost(item.name, rate=130, effective_date=today())
+
+ # Submitting the new rate must auto-create and submit a revaluation Stock Reconciliation.
+ self.assertTrue(isc.revaluation_entry)
+ reco_status = frappe.db.get_value("Stock Reconciliation", isc.revaluation_entry, "docstatus")
+ self.assertEqual(reco_status, 1)
+
+ stock_value = frappe.db.get_value(
+ "Bin", {"item_code": item.name, "warehouse": TEST_WAREHOUSE}, "stock_value"
+ )
+ self.assertEqual(flt(stock_value), 1300)
+
+ def test_backdated_entry_fast_qty_repost(self):
+ item = create_standard_cost_item()
+ create_item_standard_cost(item.name, rate=100, effective_date=add_days(today(), -10))
+
+ se1 = make_stock_entry(
+ item_code=item.name,
+ target=TEST_WAREHOUSE,
+ qty=10,
+ basic_rate=100,
+ posting_date=add_days(today(), -5),
+ )
+ se2 = make_stock_entry(
+ item_code=item.name,
+ target=TEST_WAREHOUSE,
+ qty=5,
+ basic_rate=100,
+ posting_date=add_days(today(), -2),
+ )
+ se0 = make_stock_entry(
+ item_code=item.name,
+ target=TEST_WAREHOUSE,
+ qty=20,
+ basic_rate=100,
+ posting_date=add_days(today(), -7),
+ )
+
+ def sle(se):
+ return frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"voucher_no": se.name, "is_cancelled": 0},
+ ["qty_after_transaction", "stock_value"],
+ as_dict=True,
+ )
+
+ self.assertEqual(flt(sle(se0).qty_after_transaction), 20)
+ self.assertEqual(flt(sle(se1).qty_after_transaction), 30)
+ self.assertEqual(flt(sle(se2).qty_after_transaction), 35)
+ self.assertEqual(flt(sle(se1).stock_value), 3000)
+ self.assertEqual(flt(sle(se2).stock_value), 3500)
+
+ bin_data = frappe.db.get_value(
+ "Bin",
+ {"item_code": item.name, "warehouse": TEST_WAREHOUSE},
+ ["actual_qty", "stock_value"],
+ as_dict=True,
+ )
+ self.assertEqual(flt(bin_data.actual_qty), 35)
+ self.assertEqual(flt(bin_data.stock_value), 3500)
+
+ self.assertFalse(frappe.db.exists("Repost Item Valuation", {"voucher_no": se0.name}))
+
+ def test_cannot_cancel(self):
+ item = create_standard_cost_item()
+ isc = create_item_standard_cost(item.name, rate=100)
+ self.assertRaises(frappe.ValidationError, isc.cancel)
+
+ def test_direct_stock_reconciliation_blocked(self):
+ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
+ create_stock_reconciliation,
+ )
+
+ item = create_standard_cost_item()
+ create_item_standard_cost(item.name, rate=100)
+ make_stock_entry(item_code=item.name, target=TEST_WAREHOUSE, qty=10, basic_rate=100)
+
+ self.assertRaises(
+ frappe.ValidationError,
+ create_stock_reconciliation,
+ item_code=item.name,
+ warehouse=TEST_WAREHOUSE,
+ qty=8,
+ rate=120,
+ )
+
+ def test_backdated_transaction_blocked(self):
+ item = create_standard_cost_item()
+ create_item_standard_cost(item.name, rate=100, effective_date=today())
+
+ # R2 is enforced when the stock ledger entries are written, i.e. at submit time.
+ se = make_stock_entry(
+ item_code=item.name,
+ target=TEST_WAREHOUSE,
+ qty=10,
+ basic_rate=100,
+ posting_date=add_days(today(), -3),
+ do_not_submit=True,
+ )
+ self.assertRaises(frappe.ValidationError, se.submit)
+
+ def test_manufacturing_variance_books_to_stock_adjustment(self):
+ # RM standard 50, FG standard 200. Consuming 5 RM (250) to produce 1 FG (200) leaves a
+ # 50 manufacturing variance, which must land in the company's Stock Adjustment account.
+ rm = create_standard_cost_item()
+ fg = create_standard_cost_item()
+ create_item_standard_cost(rm.name, rate=50, company=PI_COMPANY)
+ create_item_standard_cost(fg.name, rate=200, company=PI_COMPANY)
+
+ make_stock_entry(item_code=rm.name, to_warehouse=PI_STORES, company=PI_COMPANY, qty=10, basic_rate=50)
+
+ se = frappe.new_doc("Stock Entry")
+ se.purpose = "Repack"
+ se.stock_entry_type = "Repack"
+ se.company = PI_COMPANY
+ se.append("items", {"item_code": rm.name, "s_warehouse": PI_STORES, "qty": 5})
+ se.append("items", {"item_code": fg.name, "t_warehouse": PI_FG, "qty": 1, "is_finished_item": 1})
+ se.insert()
+ se.submit()
+
+ # FG is valued at its own standard, not the rolled-up RM cost.
+ fg_sle = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"voucher_no": se.name, "item_code": fg.name, "is_cancelled": 0},
+ ["valuation_rate", "stock_value_difference"],
+ as_dict=True,
+ )
+ self.assertEqual(flt(fg_sle.valuation_rate), 200)
+ self.assertEqual(flt(fg_sle.stock_value_difference), 200)
+
+ stock_adj = frappe.get_cached_value("Company", PI_COMPANY, "stock_adjustment_account")
+ net = frappe.db.sql(
+ "select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s",
+ (se.name, stock_adj),
+ )[0][0]
+ self.assertEqual(flt(net), 50)
+
+ def test_valuation_method_change_blocked_with_stock(self):
+ item = create_standard_cost_item()
+ create_item_standard_cost(item.name, rate=100)
+ make_stock_entry(item_code=item.name, target=TEST_WAREHOUSE, qty=10, basic_rate=100)
+
+ item.reload()
+ item.valuation_method = "FIFO"
+ self.assertRaises(frappe.ValidationError, item.save)
+
+ def test_batched_item_revalued_across_warehouses(self):
+ # A rate change must revalue a batched Standard Cost item in every warehouse, posted as a
+ # pure value change without a serial/batch bundle.
+ item = create_standard_cost_item(
+ has_batch_no=1, create_new_batch=1, batch_number_series="SC-BATCH-.####"
+ )
+ create_item_standard_cost(
+ item.name, rate=100, company=PI_COMPANY, effective_date=add_days(today(), -5)
+ )
+
+ make_stock_entry(
+ item_code=item.name,
+ to_warehouse=PI_STORES,
+ company=PI_COMPANY,
+ qty=3,
+ basic_rate=100,
+ use_serial_batch_fields=1,
+ posting_date=add_days(today(), -3),
+ )
+ make_stock_entry(
+ item_code=item.name,
+ to_warehouse=PI_FG,
+ company=PI_COMPANY,
+ qty=2,
+ basic_rate=100,
+ use_serial_batch_fields=1,
+ posting_date=add_days(today(), -3),
+ )
+
+ isc = create_item_standard_cost(item.name, rate=150, company=PI_COMPANY, effective_date=today())
+ self.assertTrue(isc.revaluation_entry)
+
+ for warehouse, qty in ((PI_STORES, 3), (PI_FG, 2)):
+ stock_value = frappe.db.get_value(
+ "Bin", {"item_code": item.name, "warehouse": warehouse}, "stock_value"
+ )
+ self.assertEqual(flt(stock_value), qty * 150)
+
+ def test_serialized_item_revalued_across_warehouses(self):
+ item = create_standard_cost_item(has_serial_no=1, serial_no_series="SC-SER-.####")
+ create_item_standard_cost(
+ item.name, rate=100, company=PI_COMPANY, effective_date=add_days(today(), -5)
+ )
+
+ make_stock_entry(
+ item_code=item.name,
+ to_warehouse=PI_STORES,
+ company=PI_COMPANY,
+ qty=3,
+ basic_rate=100,
+ use_serial_batch_fields=1,
+ posting_date=add_days(today(), -3),
+ )
+ make_stock_entry(
+ item_code=item.name,
+ to_warehouse=PI_FG,
+ company=PI_COMPANY,
+ qty=2,
+ basic_rate=100,
+ use_serial_batch_fields=1,
+ posting_date=add_days(today(), -3),
+ )
+
+ isc = create_item_standard_cost(item.name, rate=150, company=PI_COMPANY, effective_date=today())
+ self.assertTrue(isc.revaluation_entry)
+
+ for warehouse, qty in ((PI_STORES, 3), (PI_FG, 2)):
+ stock_value = frappe.db.get_value(
+ "Bin", {"item_code": item.name, "warehouse": warehouse}, "stock_value"
+ )
+ self.assertEqual(flt(stock_value), qty * 150)
+
+ def test_standard_rate_cache_invalidated_after_submit(self):
+ from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_item_standard_rate
+
+ item = create_standard_cost_item()
+
+ # Read (and request-cache) the rate before any Item Standard Cost exists.
+ self.assertIsNone(get_item_standard_rate(item.name, TEST_COMPANY))
+
+ create_item_standard_cost(item.name, rate=100)
+
+ # The submit must have invalidated the cache, so this reads the freshly submitted rate.
+ self.assertEqual(flt(get_item_standard_rate(item.name, TEST_COMPANY)), 100)
+
+ def test_pr_stock_value_excludes_rejected_warehouse(self):
+ # Accepted and rejected stock for one receipt row share voucher_detail_no. The standard-cost
+ # SRBNB split must clear only the accepted warehouse's value, not accepted + rejected.
+ from erpnext.accounts.doctype.purchase_invoice.services.gl_composer import (
+ PurchaseInvoiceGLComposer,
+ )
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
+
+ item = create_standard_cost_item()
+ create_item_standard_cost(item.name, rate=100, company=PI_COMPANY)
+
+ rejected_warehouse = create_warehouse("_Test SC Rejected Warehouse", company=PI_COMPANY)
+
+ # Receive 10 accepted + 2 rejected at a billed rate of 150; both SLEs value at the standard 100.
+ pr = make_purchase_receipt(
+ item_code=item.name,
+ company=PI_COMPANY,
+ warehouse=PI_STORES,
+ qty=10,
+ rejected_qty=2,
+ rejected_warehouse=rejected_warehouse,
+ rate=150,
+ )
+
+ # Method body uses only `item`, so it can be called unbound.
+ def pr_value(stock_qty):
+ mock_item = frappe._dict(
+ purchase_receipt=pr.name, pr_detail=pr.items[0].name, stock_qty=stock_qty
+ )
+ return flt(PurchaseInvoiceGLComposer.get_pr_stock_value(None, mock_item))
+
+ # Billing all 10: accepted only (10 * 100), not accepted + rejected (12 * 100).
+ self.assertEqual(pr_value(10), 1000)
+ # Billing only 4 of the 10 accepted units: pro-rated to the invoiced qty (4 * 100).
+ self.assertEqual(pr_value(4), 400)
+
+ def test_pr_books_variance_to_ppv_account(self):
+ # Receiving a Standard Cost item at a rate above the standard must book the difference to the
+ # Purchase Price Variance account, not the default expense (COGS) account.
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+
+ ppv_account = ensure_ppv_account(PI_COMPANY)
+ cogs_account = frappe.get_cached_value("Company", PI_COMPANY, "default_expense_account")
+
+ item = create_standard_cost_item()
+ create_item_standard_cost(item.name, rate=130, company=PI_COMPANY)
+
+ # Receive 1 @ 200: stock booked at standard 130, the 70 difference is the purchase price variance.
+ pr = make_purchase_receipt(
+ item_code=item.name, company=PI_COMPANY, warehouse=PI_STORES, qty=1, rate=200
+ )
+
+ def booked(account):
+ return flt(
+ frappe.db.sql(
+ "select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s and is_cancelled=0",
+ (pr.name, account),
+ )[0][0]
+ )
+
+ self.assertEqual(booked(ppv_account), 70)
+ self.assertEqual(booked(cogs_account), 0)
+
+ def test_pr_throws_without_ppv_account(self):
+ # Receiving a Standard Cost item with a variance but no PPV account configured must error.
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+
+ previous = frappe.get_cached_value("Company", PI_COMPANY, "default_purchase_price_variance_account")
+ frappe.db.set_value("Company", PI_COMPANY, "default_purchase_price_variance_account", None)
+ frappe.clear_cache(doctype="Company")
+ try:
+ item = create_standard_cost_item()
+ create_item_standard_cost(item.name, rate=130, company=PI_COMPANY)
+ self.assertRaises(
+ frappe.ValidationError,
+ make_purchase_receipt,
+ item_code=item.name,
+ company=PI_COMPANY,
+ warehouse=PI_STORES,
+ qty=1,
+ rate=200,
+ )
+ finally:
+ frappe.db.set_value("Company", PI_COMPANY, "default_purchase_price_variance_account", previous)
+ frappe.clear_cache(doctype="Company")
+
+ def test_revaluation_posted_after_same_day_movement(self):
+ # A movement earlier on the effective date must not end up after the revaluation, otherwise the
+ # reco would backdate the current quantity ahead of it.
+ item = create_standard_cost_item()
+ create_item_standard_cost(item.name, rate=100, effective_date=add_days(today(), -2))
+
+ se = make_stock_entry(
+ item_code=item.name, target=TEST_WAREHOUSE, qty=10, basic_rate=100, posting_date=today()
+ )
+
+ isc = create_item_standard_cost(item.name, rate=150, effective_date=today())
+
+ reco_time = frappe.db.get_value("Stock Reconciliation", isc.revaluation_entry, "posting_time")
+ se_time = frappe.db.get_value(
+ "Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0}, "posting_time"
+ )
+ self.assertGreaterEqual(str(reco_time), str(se_time))
+
+ stock_value = frappe.db.get_value(
+ "Bin", {"item_code": item.name, "warehouse": TEST_WAREHOUSE}, "stock_value"
+ )
+ self.assertEqual(flt(stock_value), 1500)
diff --git a/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py
index 4af4c93bd6d..7cd7e3d2622 100644
--- a/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py
+++ b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py
@@ -240,13 +240,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
divisional_loss -= rejected_item_cost
if divisional_loss:
- loss_account = (
- doc.get_company_default("default_expense_account", ignore_validation=True)
- or stock_asset_rbnb
- )
-
- if doc.is_return and item.expense_account:
- loss_account = item.expense_account
+ loss_account = self.get_divisional_loss_account(item, stock_asset_rbnb)
cost_center = item.cost_center or frappe.get_cached_value(
"Company", doc.company, "cost_center"
@@ -359,6 +353,30 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
+ "\n".join(warehouse_with_no_account)
)
+ def get_divisional_loss_account(self, item, stock_asset_rbnb):
+ """Account that absorbs the difference between the document value and the value actually
+ booked into stock. For a Standard Cost item this difference is a purchase price variance
+ (receipt rate vs standard rate), so it goes to the Purchase Price Variance account; for all
+ other items it keeps the existing behaviour (default expense account, or the item's expense
+ account on a return)."""
+ from erpnext.stock.utils import get_valuation_method
+
+ doc = self.doc
+ if item.item_code and get_valuation_method(item.item_code, doc.company) == "Standard Cost":
+ from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
+ get_purchase_price_variance_account,
+ )
+
+ return get_purchase_price_variance_account(item.item_code, doc.company)
+
+ loss_account = (
+ doc.get_company_default("default_expense_account", ignore_validation=True) or stock_asset_rbnb
+ )
+ if doc.is_return and item.expense_account:
+ loss_account = item.expense_account
+
+ return loss_account
+
def _make_tax_gl_entries(self, gl_entries: list, via_landed_cost_voucher: bool = False) -> None:
doc = self.doc
negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in doc.get("items")])
diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
index 99363c760f9..8ec74a3df4d 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
@@ -234,12 +234,21 @@ class StockLedgerEntry(Document):
self.throw_error_message(f"Item {self.item_code} must be a stock Item")
if item_detail.has_serial_no or item_detail.has_batch_no:
- if not self.serial_and_batch_bundle:
+ if not self.serial_and_batch_bundle and not self.is_standard_cost_revaluation():
self.throw_error_message(f"Serial No / Batch No are mandatory for Item {self.item_code}")
if self.serial_and_batch_bundle and not item_detail.has_serial_no and not item_detail.has_batch_no:
self.throw_error_message(f"Serial No and Batch No are not allowed for Item {self.item_code}")
+ def is_standard_cost_revaluation(self):
+ """A Standard Cost item is revalued through a Stock Reconciliation that changes the rate only
+ (qty unchanged); it carries no serial/batch bundle, so the bundle requirement is bypassed."""
+ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import is_standard_cost_item
+
+ return self.voucher_type == "Stock Reconciliation" and is_standard_cost_item(
+ self.item_code, self.company
+ )
+
def throw_error_message(self, message, exception=frappe.ValidationError):
frappe.throw(_(message), exception)
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index d165b802889..5bba06f9a67 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -7,7 +7,7 @@ from datetime import timedelta
import frappe
from frappe import _, bold, json, msgprint
from frappe.query_builder.functions import Sum
-from frappe.utils import add_to_date, cint, cstr, flt, now
+from frappe.utils import add_to_date, cint, cstr, flt, get_link_to_form, now
from frappe.utils.data import DateTimeLikeObject
import erpnext
@@ -21,7 +21,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_reconciliation_item.stock_reconciliation_item import StockReconciliationItem
-from erpnext.stock.utils import get_incoming_rate, get_stock_balance
+from erpnext.stock.utils import get_incoming_rate, get_stock_balance, get_valuation_method
class OpeningEntryAccountError(frappe.ValidationError):
@@ -71,6 +71,7 @@ class StockReconciliation(StockController):
sbb = SerialBatchBundleService(self)
+ self.validate_standard_cost_items()
self.validate_items_exist()
if not self.expense_account:
self.expense_account = frappe.get_cached_value(
@@ -172,6 +173,20 @@ class StockReconciliation(StockController):
}
)
+ def validate_standard_cost_items(self):
+ """Stock Reconciliation is not allowed for Standard Cost items — their rate is changed
+ only through the Item Standard Cost doctype (which creates the revaluation reco itself)."""
+ if self.flags.via_item_standard_cost:
+ return
+
+ for item in self.items:
+ if item.item_code and is_standard_cost_item(item.item_code, self.company):
+ frappe.throw(
+ _(
+ "Row #{0}: Stock Reconciliation is not allowed for Item {1}, which uses the Standard Cost valuation method. Change its rate through Item Standard Cost instead."
+ ).format(item.idx, get_link_to_form("Item", item.item_code))
+ )
+
def set_current_serial_and_batch_bundle(self, voucher_detail_no=None, save=False) -> None:
"""Set Serial and Batch Bundle for each item"""
for item in self.items:
@@ -181,6 +196,12 @@ class StockReconciliation(StockController):
if not item.item_code:
continue
+ # Standard Cost revaluation recos are pure value changes: qty is unchanged and the SLE is
+ # revalued at the standard rate, so no serial/batch bundle is created (see update_stock_ledger,
+ # which routes these rows through the single revaluation SLE path).
+ if is_standard_cost_item(item.item_code, self.company):
+ continue
+
item_details = frappe.get_cached_value(
"Item", item.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
)
@@ -431,6 +452,10 @@ class StockReconciliation(StockController):
if not item.item_code:
continue
+ # Standard Cost revaluation recos are pure value changes; no serial/batch bundle needed.
+ if is_standard_cost_item(item.item_code, self.company):
+ continue
+
if item.use_serial_batch_fields:
continue
@@ -551,7 +576,9 @@ class StockReconciliation(StockController):
if item.valuation_rate is None:
item.valuation_rate = item_dict.get("rate")
- if item_dict.get("serial_nos"):
+ # Standard Cost items are revalued by rate only; don't pull serial nos onto the row, or a
+ # serial/batch bundle would be built for what must stay a pure value-change SLE.
+ if item_dict.get("serial_nos") and not is_standard_cost_item(item.item_code, self.company):
item.current_serial_no = item_dict.get("serial_nos")
if self.purpose == "Stock Reconciliation" and not item.serial_no and item.qty:
item.serial_no = item.current_serial_no
@@ -767,7 +794,12 @@ class StockReconciliation(StockController):
"Item", row.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
)
- if item.has_serial_no or item.has_batch_no:
+ # A Standard Cost item is revalued by rate alone (qty unchanged, valuation from the standard
+ # rate), so even a serialized/batched one is posted through the single revaluation SLE path
+ # without a serial/batch bundle, the same as a non-serial item.
+ if (item.has_serial_no or item.has_batch_no) and not is_standard_cost_item(
+ row.item_code, self.company
+ ):
self.get_sle_for_serialized_items(row, sl_entries)
else:
if row.serial_and_batch_bundle:
@@ -1134,6 +1166,10 @@ class StockReconciliation(StockController):
self._cancel()
+def is_standard_cost_item(item_code, company):
+ return get_valuation_method(item_code, company) == "Standard Cost"
+
+
@frappe.whitelist()
def get_items(
warehouse: str,
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index f9b46cf6e4f..48981955052 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.json
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.json
@@ -134,7 +134,7 @@
"fieldname": "valuation_method",
"fieldtype": "Select",
"label": "Default Valuation Method",
- "options": "FIFO\nMoving Average\nLIFO"
+ "options": "FIFO\nMoving Average\nLIFO\nStandard Cost"
},
{
"description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.",
@@ -602,7 +602,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2026-06-13 12:38:02.202183",
+ "modified": "2026-06-26 10:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",
diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py
index 9d0ad704480..5eef60dcc1a 100644
--- a/erpnext/stock/serial_batch_bundle.py
+++ b/erpnext/stock/serial_batch_bundle.py
@@ -1342,6 +1342,15 @@ class SerialBatchCreation:
def set_serial_batch_entries(self, doc):
incoming_rate = self.get("incoming_rate")
+ standard_rate = self.get_standard_cost_rate()
+ if standard_rate is not None:
+ # Standard Cost values every serial/batch at the same rate, so the bundle entries
+ # must carry the standard rate (not the document/billed rate) to stay consistent
+ # with the standard-valued Stock Ledger Entry.
+ incoming_rate = standard_rate
+ self.serial_nos_valuation = None
+ self.batches_valuation = None
+
precision = frappe.get_precision("Serial and Batch Entry", "qty")
if self.get("serial_nos"):
serial_no_wise_batch = frappe._dict({})
@@ -1378,6 +1387,25 @@ class SerialBatchCreation:
},
)
+ def get_standard_cost_rate(self):
+ """Return the standard valuation rate for the item if its valuation method is
+ Standard Cost, else None — used to value bundle entries at standard."""
+ from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_item_standard_rate
+ from erpnext.stock.utils import get_valuation_method
+
+ company = self.get("company")
+ if not company and self.get("warehouse"):
+ company = frappe.get_cached_value("Warehouse", self.warehouse, "company")
+
+ if not company or get_valuation_method(self.item_code, company) != "Standard Cost":
+ return None
+
+ posting_date = self.get("posting_date")
+ if not posting_date and self.get("posting_datetime"):
+ posting_date = getdate(self.posting_datetime)
+
+ return get_item_standard_rate(self.item_code, company, posting_date)
+
def create_batch(self):
from erpnext.stock.doctype.batch.batch import make_batch
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index d444da767e5..c5ecdc130fa 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -17,6 +17,7 @@ from frappe.utils import (
format_date,
get_datetime,
get_link_to_form,
+ getdate,
now,
nowdate,
nowtime,
@@ -53,6 +54,52 @@ class SerialNoExistsInFutureTransaction(frappe.ValidationError):
pass
+def validate_standard_cost_posting_date(sl_entries):
+ """R2: a Standard Cost item's stock transaction cannot be dated before the latest Item
+ Standard Cost effective date. A backdated entry would slip in behind the standard-rate
+ revaluation, making its on-hand snapshot stale and forcing a repost — which Standard Cost
+ deliberately avoids. Enforced here so every stock voucher is covered uniformly."""
+ from erpnext.stock.utils import get_valuation_method
+
+ checked = {}
+ for sle in sl_entries:
+ item_code = sle.get("item_code")
+ company = sle.get("company")
+ posting_date = sle.get("posting_date")
+ if not item_code or not company or not posting_date:
+ continue
+
+ key = (item_code, company)
+ if key not in checked:
+ latest_isc = None
+ if get_valuation_method(item_code, company) == "Standard Cost":
+ latest_isc = frappe.db.get_value(
+ "Item Standard Cost",
+ {"item_code": item_code, "company": company, "docstatus": 1},
+ ["name", "effective_date"],
+ order_by="effective_date desc",
+ as_dict=True,
+ )
+ checked[key] = latest_isc
+
+ latest_isc = checked[key]
+ if latest_isc and getdate(posting_date) < getdate(latest_isc.effective_date):
+ effective_date = frappe.bold(frappe.format(latest_isc.effective_date, "Date"))
+ frappe.throw(
+ _(
+ "Cannot post Standard Cost item {0} on {1}: it is before {2}, the effective date of its latest Standard Valuation Rate {3}."
+ ).format(
+ get_link_to_form("Item", item_code),
+ frappe.bold(frappe.format(posting_date, "Date")),
+ effective_date,
+ get_link_to_form("Item Standard Cost", latest_isc.name),
+ )
+ + "
"
+ + _("Post this entry on or after {0}.").format(effective_date),
+ title=_("Backdated Entry Not Allowed"),
+ )
+
+
def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False):
"""Create SL entries from SL entry dicts
@@ -71,6 +118,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
if cancelled:
validate_cancellation(sl_entries)
set_as_cancel(sl_entries[0].get("voucher_type"), sl_entries[0].get("voucher_no"))
+ else:
+ validate_standard_cost_posting_date(sl_entries)
args = get_args_for_future_sle(sl_entries[0])
future_sle_exists(args, sl_entries)
@@ -843,6 +892,29 @@ class update_entries_after:
indicator="blue",
)
+ def process_standard_cost(self, sle):
+ from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_item_standard_rate
+
+ rate = get_item_standard_rate(sle.item_code, self.company, sle.posting_date)
+ if rate is None:
+ frappe.throw(
+ _(
+ "No Standard Valuation Rate found for Item {0} in Company {1} as on {2}. Please create an Item Standard Cost record."
+ ).format(bold(sle.item_code), bold(self.company), bold(sle.posting_date))
+ )
+
+ if sle.voucher_type == "Stock Reconciliation" and sle.get("qty_after_transaction") is not None:
+ self.wh_data.qty_after_transaction = flt(sle.qty_after_transaction)
+ else:
+ self.wh_data.qty_after_transaction += flt(sle.actual_qty)
+
+ self.wh_data.valuation_rate = rate
+ self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(rate)
+ self.wh_data.stock_queue = [[self.wh_data.qty_after_transaction, rate]]
+
+ if flt(sle.actual_qty) > 0:
+ sle.incoming_rate = rate
+
def process_sle(self, sle):
# previous sle data for this warehouse
key = (sle.item_code, sle.warehouse)
@@ -897,7 +969,11 @@ class update_entries_after:
if sle.get(dimension.get("fieldname")):
has_dimensions = True
- if sle.serial_and_batch_bundle:
+ if self.valuation_method == "Standard Cost":
+ # Inventory is always carried at the standard rate effective on the posting date;
+ # FIFO/Moving Average/serial-batch valuation is bypassed entirely.
+ self.process_standard_cost(sle)
+ elif sle.serial_and_batch_bundle:
self.calculate_valuation_for_serial_batch_bundle(sle)
elif sle.serial_no and not self.args.get("sle_id"):
# Only run in reposting
@@ -2065,21 +2141,50 @@ def update_qty_in_future_sle(args, allow_negative_stock=False):
detail = next_stock_reco_detail[0]
datetime_limit_condition = get_datetime_limit_condition(detail)
- frappe.db.sql( # nosemgrep
- f"""
- update `tabStock Ledger Entry`
- set qty_after_transaction = qty_after_transaction + {qty_shift}
- where
- item_code = %(item_code)s
- and warehouse = %(warehouse)s
- and is_cancelled = 0
- and (
- posting_datetime > %(posting_datetime)s
- )
- {datetime_limit_condition}
- """,
- args,
- )
+ if get_valuation_method(args.get("item_code"), args.get("company")) == "Standard Cost":
+ # Standard Cost inventory is always carried at the standard rate, so a backdated entry only
+ # shifts future balances — no full repost is needed. Update qty and value in place:
+ # stock_value = qty_after_transaction * standard rate, which is constant across this range
+ # (a rate change posts a reconciliation that bounds it). stock_value_difference is unchanged
+ # because every future balance shifts by the same amount.
+ from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_item_standard_rate
+
+ standard_rate = flt(
+ get_item_standard_rate(args.get("item_code"), args.get("company"), args.get("posting_date"))
+ )
+
+ frappe.db.sql( # nosemgrep
+ f"""
+ update `tabStock Ledger Entry`
+ set stock_value = (qty_after_transaction + {qty_shift}) * {standard_rate},
+ qty_after_transaction = qty_after_transaction + {qty_shift}
+ where
+ item_code = %(item_code)s
+ and warehouse = %(warehouse)s
+ and is_cancelled = 0
+ and (
+ posting_datetime > %(posting_datetime)s
+ )
+ {datetime_limit_condition}
+ """,
+ args,
+ )
+ else:
+ frappe.db.sql( # nosemgrep
+ f"""
+ update `tabStock Ledger Entry`
+ set qty_after_transaction = qty_after_transaction + {qty_shift}
+ where
+ item_code = %(item_code)s
+ and warehouse = %(warehouse)s
+ and is_cancelled = 0
+ and (
+ posting_datetime > %(posting_datetime)s
+ )
+ {datetime_limit_condition}
+ """,
+ args,
+ )
validate_negative_qty_in_future_sle(args, allow_negative_stock)