feat: Standard Valuation Rate (#56570)

* feat: standard rate valuation

* fix: greptile comments

* fix: PPV account should be mandatory for standard cost valuation
This commit is contained in:
rohitwaghchaure
2026-06-28 20:35:01 +05:30
committed by GitHub
parent 081fbe9e3f
commit 6c38856f65
18 changed files with 1374 additions and 63 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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",

View File

@@ -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):

View File

@@ -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",

View File

@@ -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

View File

@@ -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",

View File

@@ -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,
},
};
});
},
});

View File

@@ -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
}

View File

@@ -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},
)

View File

@@ -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)

View File

@@ -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")])

View File

@@ -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)

View File

@@ -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,

View File

@@ -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",

View File

@@ -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

View File

@@ -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),
)
+ "<br><br>"
+ _("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)