mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-14 15:11:52 +00:00
fix: manufacturing variance for standard cost valuation (#56684)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user