mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-19 19:37:56 +00:00
fix: manufacturing variance for standard cost valuation (#56684)
This commit is contained in:
@@ -130,6 +130,7 @@
|
|||||||
"column_break_32",
|
"column_break_32",
|
||||||
"stock_adjustment_account",
|
"stock_adjustment_account",
|
||||||
"default_purchase_price_variance_account",
|
"default_purchase_price_variance_account",
|
||||||
|
"default_manufacturing_variance_account",
|
||||||
"stock_received_but_not_billed",
|
"stock_received_but_not_billed",
|
||||||
"stock_delivered_but_not_billed",
|
"stock_delivered_but_not_billed",
|
||||||
"disable_sdbnb_in_sr",
|
"disable_sdbnb_in_sr",
|
||||||
@@ -499,7 +500,18 @@
|
|||||||
"ignore_user_permissions": 1,
|
"ignore_user_permissions": 1,
|
||||||
"label": "Default Purchase Price Variance Account",
|
"label": "Default Purchase Price Variance Account",
|
||||||
"no_copy": 1,
|
"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",
|
"fieldname": "column_break_32",
|
||||||
@@ -1014,7 +1026,7 @@
|
|||||||
"image_field": "company_logo",
|
"image_field": "company_logo",
|
||||||
"is_tree": 1,
|
"is_tree": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-06-26 10:05:00.000000",
|
"modified": "2026-07-01 11:48:07.853494",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Setup",
|
"module": "Setup",
|
||||||
"name": "Company",
|
"name": "Company",
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class Company(NestedSet):
|
|||||||
default_inventory_account: DF.Link | None
|
default_inventory_account: DF.Link | None
|
||||||
default_letter_head: DF.Link | None
|
default_letter_head: DF.Link | None
|
||||||
default_letter_head_report: 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_operating_cost_account: DF.Link | None
|
||||||
default_payable_account: DF.Link | None
|
default_payable_account: DF.Link | None
|
||||||
default_provisional_account: DF.Link | None
|
default_provisional_account: DF.Link | None
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"purchase_expense_account",
|
"purchase_expense_account",
|
||||||
"purchase_expense_contra_account",
|
"purchase_expense_contra_account",
|
||||||
"purchase_price_variance_account",
|
"purchase_price_variance_account",
|
||||||
|
"manufacturing_variance_account",
|
||||||
"selling_defaults",
|
"selling_defaults",
|
||||||
"column_break_sales",
|
"column_break_sales",
|
||||||
"vf_selling_cost_center",
|
"vf_selling_cost_center",
|
||||||
@@ -198,6 +199,14 @@
|
|||||||
"options": "Account",
|
"options": "Account",
|
||||||
"show_description_on_click": 1
|
"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",
|
"fieldname": "column_break_purchase",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
@@ -365,7 +374,7 @@
|
|||||||
],
|
],
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-06-26 10:05:00.000000",
|
"modified": "2026-07-01 11:48:07.853494",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Stock",
|
"module": "Stock",
|
||||||
"name": "Item Default",
|
"name": "Item Default",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class ItemDefault(Document):
|
|||||||
expense_account: DF.Link | None
|
expense_account: DF.Link | None
|
||||||
income_account: DF.Link | None
|
income_account: DF.Link | None
|
||||||
inventory_account_currency: DF.Link | None
|
inventory_account_currency: DF.Link | None
|
||||||
|
manufacturing_variance_account: DF.Link | None
|
||||||
parent: DF.Data
|
parent: DF.Data
|
||||||
parentfield: DF.Data
|
parentfield: DF.Data
|
||||||
parenttype: DF.Data
|
parenttype: DF.Data
|
||||||
|
|||||||
@@ -260,6 +260,29 @@ def get_purchase_price_variance_account(item_code, company):
|
|||||||
return account
|
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.whitelist()
|
||||||
@frappe.validate_and_sanitize_search_inputs
|
@frappe.validate_and_sanitize_search_inputs
|
||||||
def get_standard_cost_items(
|
def get_standard_cost_items(
|
||||||
|
|||||||
@@ -58,10 +58,34 @@ def ensure_ppv_account(company):
|
|||||||
return account
|
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):
|
class TestItemStandardCost(ERPNextTestSuite):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
ensure_ppv_account(TEST_COMPANY)
|
ensure_ppv_account(TEST_COMPANY)
|
||||||
ensure_ppv_account(PI_COMPANY)
|
ensure_ppv_account(PI_COMPANY)
|
||||||
|
ensure_mfg_variance_account(PI_COMPANY)
|
||||||
|
|
||||||
def test_only_for_standard_cost_items(self):
|
def test_only_for_standard_cost_items(self):
|
||||||
item = make_item(properties={"valuation_method": "FIFO", "is_stock_item": 1})
|
item = make_item(properties={"valuation_method": "FIFO", "is_stock_item": 1})
|
||||||
@@ -246,9 +270,11 @@ class TestItemStandardCost(ERPNextTestSuite):
|
|||||||
)
|
)
|
||||||
self.assertRaises(frappe.ValidationError, se.submit)
|
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
|
# 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()
|
rm = create_standard_cost_item()
|
||||||
fg = create_standard_cost_item()
|
fg = create_standard_cost_item()
|
||||||
create_item_standard_cost(rm.name, rate=50, company=PI_COMPANY)
|
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.valuation_rate), 200)
|
||||||
self.assertEqual(flt(fg_sle.stock_value_difference), 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")
|
stock_adj = frappe.get_cached_value("Company", PI_COMPANY, "stock_adjustment_account")
|
||||||
net = frappe.db.sql(
|
self.assertEqual(gl_net(stock_adj), 0)
|
||||||
"select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s",
|
|
||||||
(se.name, stock_adj),
|
def test_manufacturing_variance_includes_additional_costs(self):
|
||||||
)[0][0]
|
# The variance is (full consumed cost - standard value), where consumed cost includes prorated
|
||||||
self.assertEqual(flt(net), 50)
|
# 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):
|
def test_valuation_method_change_blocked_with_stock(self):
|
||||||
item = create_standard_cost_item()
|
item = create_standard_cost_item()
|
||||||
|
|||||||
@@ -38,8 +38,85 @@ class StockEntryGLComposer(BaseStockGLComposer):
|
|||||||
|
|
||||||
self._append_lcv_gl_entries(gl_entries, inventory_account_map)
|
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)
|
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(
|
def _build_additional_cost_per_item_account(
|
||||||
self, total_basic_amount: float, divide_based_on: float
|
self, total_basic_amount: float, divide_based_on: float
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class BaseStockGLComposer(BaseGLComposer):
|
|||||||
if not inventory_account_map:
|
if not inventory_account_map:
|
||||||
inventory_account_map = doc.get_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)
|
voucher_details = self.get_voucher_details(default_expense_account, default_cost_center, sle_map)
|
||||||
|
|
||||||
gl_list = []
|
gl_list = []
|
||||||
|
|||||||
Reference in New Issue
Block a user