fix: manufacturing variance for standard cost valuation (#56684)

This commit is contained in:
rohitwaghchaure
2026-07-01 14:04:14 +05:30
committed by GitHub
parent 04cbb5da75
commit 58e5755780
8 changed files with 242 additions and 11 deletions

View File

@@ -130,6 +130,7 @@
"column_break_32",
"stock_adjustment_account",
"default_purchase_price_variance_account",
"default_manufacturing_variance_account",
"stock_received_but_not_billed",
"stock_delivered_but_not_billed",
"disable_sdbnb_in_sr",
@@ -499,7 +500,18 @@
"ignore_user_permissions": 1,
"label": "Default Purchase Price Variance Account",
"no_copy": 1,
"options": "Account"
"options": "Account",
"show_description_on_click": 1
},
{
"description": "For Standard Cost items: the Manufacture/Repack consumed cost vs standard rate difference is booked here.",
"fieldname": "default_manufacturing_variance_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Manufacturing Variance Account",
"no_copy": 1,
"options": "Account",
"show_description_on_click": 1
},
{
"fieldname": "column_break_32",
@@ -1014,7 +1026,7 @@
"image_field": "company_logo",
"is_tree": 1,
"links": [],
"modified": "2026-06-26 10:05:00.000000",
"modified": "2026-07-01 11:48:07.853494",
"modified_by": "Administrator",
"module": "Setup",
"name": "Company",

View File

@@ -80,6 +80,7 @@ class Company(NestedSet):
default_inventory_account: DF.Link | None
default_letter_head: DF.Link | None
default_letter_head_report: DF.Link | None
default_manufacturing_variance_account: DF.Link | None
default_operating_cost_account: DF.Link | None
default_payable_account: DF.Link | None
default_provisional_account: DF.Link | None

View File

@@ -35,6 +35,7 @@
"purchase_expense_account",
"purchase_expense_contra_account",
"purchase_price_variance_account",
"manufacturing_variance_account",
"selling_defaults",
"column_break_sales",
"vf_selling_cost_center",
@@ -198,6 +199,14 @@
"options": "Account",
"show_description_on_click": 1
},
{
"description": "For Standard Cost items: the Manufacture/Repack consumed cost vs standard rate difference is booked here. Falls back to the Company's Default Manufacturing Variance Account.",
"fieldname": "manufacturing_variance_account",
"fieldtype": "Link",
"label": "Manufacturing Variance Account",
"options": "Account",
"show_description_on_click": 1
},
{
"fieldname": "column_break_purchase",
"fieldtype": "Column Break"
@@ -365,7 +374,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-06-26 10:05:00.000000",
"modified": "2026-07-01 11:48:07.853494",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item Default",

View File

@@ -28,6 +28,7 @@ class ItemDefault(Document):
expense_account: DF.Link | None
income_account: DF.Link | None
inventory_account_currency: DF.Link | None
manufacturing_variance_account: DF.Link | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View File

@@ -260,6 +260,29 @@ def get_purchase_price_variance_account(item_code, company):
return account
def get_manufacturing_variance_account(item_code, company):
"""Resolve the Manufacturing Variance account for a Standard Cost item: the per-company Item Default
override if set, otherwise the Company default. During Manufacture/Repack this account absorbs the
difference between the consumed (raw material + additional) cost and the finished good's standard rate."""
account = frappe.db.get_value(
"Item Default",
{"parent": item_code, "company": company},
"manufacturing_variance_account",
)
if not account:
account = frappe.get_cached_value("Company", company, "default_manufacturing_variance_account")
if not account:
frappe.throw(
_(
"Please set a Manufacturing Variance Account for Item {0} or a Default Manufacturing 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(

View File

@@ -58,10 +58,34 @@ def ensure_ppv_account(company):
return account
def ensure_mfg_variance_account(company):
"""Ensure `company` has a Default Manufacturing Variance Account so Manufacture/Repack entries of
Standard Cost finished goods can book the consumed-cost-vs-standard difference."""
account = frappe.get_cached_value("Company", company, "default_manufacturing_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="Manufacturing 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_manufacturing_variance_account", account)
return account
class TestItemStandardCost(ERPNextTestSuite):
def setUp(self):
ensure_ppv_account(TEST_COMPANY)
ensure_ppv_account(PI_COMPANY)
ensure_mfg_variance_account(PI_COMPANY)
def test_only_for_standard_cost_items(self):
item = make_item(properties={"valuation_method": "FIFO", "is_stock_item": 1})
@@ -246,9 +270,11 @@ class TestItemStandardCost(ERPNextTestSuite):
)
self.assertRaises(frappe.ValidationError, se.submit)
def test_manufacturing_variance_books_to_stock_adjustment(self):
def test_manufacturing_variance_books_to_variance_account(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.
# 50 (unfavorable) manufacturing variance, which must land in the company's Manufacturing
# Variance account, not the generic Stock Adjustment account.
mfg_variance = ensure_mfg_variance_account(PI_COMPANY)
rm = create_standard_cost_item()
fg = create_standard_cost_item()
create_item_standard_cost(rm.name, rate=50, company=PI_COMPANY)
@@ -275,12 +301,94 @@ class TestItemStandardCost(ERPNextTestSuite):
self.assertEqual(flt(fg_sle.valuation_rate), 200)
self.assertEqual(flt(fg_sle.stock_value_difference), 200)
def gl_net(account):
return flt(
frappe.db.sql(
"select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s",
(se.name, account),
)[0][0]
)
# The 50 variance is reclassified to the Manufacturing Variance account...
self.assertEqual(gl_net(mfg_variance), 50)
# ...leaving the generic Stock Adjustment account untouched.
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)
self.assertEqual(gl_net(stock_adj), 0)
def test_manufacturing_variance_includes_additional_costs(self):
# The variance is (full consumed cost - standard value), where consumed cost includes prorated
# additional costs. RM 5 x 50 = 250 plus a 30 additional cost = 280 consumed to make 1 FG valued
# at its standard 200 -> variance must be 280 - 200 = 80 (not 50).
mfg_variance = ensure_mfg_variance_account(PI_COMPANY)
additional_cost_account = "Expenses Included In Valuation - TCP1"
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.append(
"additional_costs",
{"expense_account": additional_cost_account, "description": "Freight", "amount": 30},
)
se.insert()
se.submit()
# FG is still valued at its own standard, regardless of the extra consumed 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)
def gl_net(account):
return flt(
frappe.db.sql(
"select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s",
(se.name, account),
)[0][0]
)
# Raw material (250) + additional cost (30) - standard value (200) = 80 to Manufacturing Variance.
self.assertEqual(gl_net(mfg_variance), 80)
# The additional cost is credited out of its source account (it flowed into the variance).
self.assertEqual(gl_net(additional_cost_account), -30)
def test_manufacturing_variance_account_required(self):
# Without a Manufacturing Variance account, submitting a Standard Cost Manufacture/Repack must fail.
previous = frappe.get_cached_value("Company", PI_COMPANY, "default_manufacturing_variance_account")
frappe.db.set_value("Company", PI_COMPANY, "default_manufacturing_variance_account", None)
frappe.clear_cache(doctype="Company")
try:
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()
self.assertRaises(frappe.ValidationError, se.submit)
finally:
frappe.db.set_value("Company", PI_COMPANY, "default_manufacturing_variance_account", previous)
frappe.clear_cache(doctype="Company")
def test_valuation_method_change_blocked_with_stock(self):
item = create_standard_cost_item()

View File

@@ -38,8 +38,85 @@ class StockEntryGLComposer(BaseStockGLComposer):
self._append_lcv_gl_entries(gl_entries, inventory_account_map)
if doc.purpose in ("Repack", "Manufacture"):
self._append_manufacturing_variance_gl_entries(gl_entries)
return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
def _append_manufacturing_variance_gl_entries(self, gl_entries: list) -> None:
"""For Standard Cost finished goods produced via Manufacture/Repack, stock is booked at the item's
standard rate, while the entry consumes raw-material (plus additional/landed) cost. The difference
is a manufacturing variance and is reclassified from the finished good's expense account to the
Manufacturing Variance account (mirrors Purchase Price Variance on a Purchase Receipt)."""
precision = self.get_debit_field_precision()
# Reuse the SLE map the base composer already fetched in compose() to avoid a second identical query.
sle_map = self._sle_map
for d in self.doc.get("items"):
variance = self._get_finished_good_variance(d, sle_map, precision)
if variance:
self._append_manufacturing_variance_pair(gl_entries, d, variance)
def _get_finished_good_variance(self, item, sle_map, precision) -> float:
"""Manufacturing variance for a Standard Cost finished good: the gap between the full computed
incoming cost (raw-material share + additional cost + LCV, i.e. ``amount``) and the standard value
actually booked into stock. Positive = consumed more than standard (unfavorable). 0 for anything
that is not a Standard Cost finished good."""
from erpnext.stock.utils import get_valuation_method
if not item.is_finished_item or not item.t_warehouse:
return 0.0
if get_valuation_method(item.item_code, self.doc.company) != "Standard Cost":
return 0.0
# Value actually booked into stock for this finished good = qty * standard rate.
standard_value = sum(
flt(sle.stock_value_difference) for sle in sle_map.get(item.name, []) if flt(sle.actual_qty) > 0
)
return flt(flt(item.amount) - standard_value, precision)
def _append_manufacturing_variance_pair(self, gl_entries: list, item, variance: float) -> None:
"""Reclassify ``variance`` from the finished good's expense account to its Manufacturing Variance
account, restoring the expense account to the value it would carry without Standard Cost."""
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
get_manufacturing_variance_account,
)
doc = self.doc
variance_account = get_manufacturing_variance_account(item.item_code, doc.company)
cost_center = item.cost_center or frappe.get_cached_value("Company", doc.company, "cost_center")
remarks = doc.get("remarks") or _("Manufacturing Variance for {0}").format(item.item_code)
project = item.project or doc.get("project")
gl_entries.append(
self.get_gl_dict(
{
"account": variance_account,
"against": item.expense_account,
"cost_center": cost_center,
"remarks": remarks,
"debit": variance,
"project": project,
},
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": variance_account,
"cost_center": cost_center,
"remarks": remarks,
"debit": -1 * variance,
"project": project,
},
item=item,
)
)
def _build_additional_cost_per_item_account(
self, total_basic_amount: float, divide_based_on: float
) -> dict:

View File

@@ -28,7 +28,7 @@ class BaseStockGLComposer(BaseGLComposer):
if not inventory_account_map:
inventory_account_map = doc.get_inventory_account_map()
sle_map = doc.get_stock_ledger_details()
sle_map = self._sle_map = doc.get_stock_ledger_details()
voucher_details = self.get_voucher_details(default_expense_account, default_cost_center, sle_map)
gl_list = []