From 6c38856f6598fabe4f70abc591aa74a35b42ea53 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Sun, 28 Jun 2026 20:35:01 +0530 Subject: [PATCH] feat: Standard Valuation Rate (#56570) * feat: standard rate valuation * fix: greptile comments * fix: PPV account should be mandatory for standard cost valuation --- .../purchase_invoice/services/gl_composer.py | 146 +++++- erpnext/controllers/stock_controller.py | 7 + erpnext/setup/doctype/company/company.json | 12 +- erpnext/stock/doctype/bin/bin.py | 34 +- erpnext/stock/doctype/item/item.json | 4 +- erpnext/stock/doctype/item/item.py | 27 +- .../doctype/item_default/item_default.json | 11 +- .../doctype/item_standard_cost/__init__.py | 0 .../item_standard_cost/item_standard_cost.js | 16 + .../item_standard_cost.json | 138 +++++ .../item_standard_cost/item_standard_cost.py | 299 +++++++++++ .../test_item_standard_cost.py | 487 ++++++++++++++++++ .../purchase_receipt/services/gl_composer.py | 32 +- .../stock_ledger_entry/stock_ledger_entry.py | 11 +- .../stock_reconciliation.py | 44 +- .../stock_settings/stock_settings.json | 4 +- erpnext/stock/serial_batch_bundle.py | 28 + erpnext/stock/stock_ledger.py | 137 ++++- 18 files changed, 1374 insertions(+), 63 deletions(-) create mode 100644 erpnext/stock/doctype/item_standard_cost/__init__.py create mode 100644 erpnext/stock/doctype/item_standard_cost/item_standard_cost.js create mode 100644 erpnext/stock/doctype/item_standard_cost/item_standard_cost.json create mode 100644 erpnext/stock/doctype/item_standard_cost/item_standard_cost.py create mode 100644 erpnext/stock/doctype/item_standard_cost/test_item_standard_cost.py 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)