mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 14:41:53 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
138
erpnext/stock/doctype/item_standard_cost/item_standard_cost.json
Normal file
138
erpnext/stock/doctype/item_standard_cost/item_standard_cost.json
Normal 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
|
||||
}
|
||||
299
erpnext/stock/doctype/item_standard_cost/item_standard_cost.py
Normal file
299
erpnext/stock/doctype/item_standard_cost/item_standard_cost.py
Normal 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},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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")])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user