diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json index 1244c72751d..1bf29c6801b 100644 --- a/erpnext/setup/doctype/company/company.json +++ b/erpnext/setup/doctype/company/company.json @@ -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", diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 5bd0ee104f0..2670de73902 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -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 diff --git a/erpnext/stock/doctype/item_default/item_default.json b/erpnext/stock/doctype/item_default/item_default.json index 9b753557dd8..73a25bd4cea 100644 --- a/erpnext/stock/doctype/item_default/item_default.json +++ b/erpnext/stock/doctype/item_default/item_default.json @@ -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", diff --git a/erpnext/stock/doctype/item_default/item_default.py b/erpnext/stock/doctype/item_default/item_default.py index 10651d260c6..0a92b9e8b61 100644 --- a/erpnext/stock/doctype/item_default/item_default.py +++ b/erpnext/stock/doctype/item_default/item_default.py @@ -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 diff --git a/erpnext/stock/doctype/item_standard_cost/item_standard_cost.py b/erpnext/stock/doctype/item_standard_cost/item_standard_cost.py index 09a00c886d6..8450d10791e 100644 --- a/erpnext/stock/doctype/item_standard_cost/item_standard_cost.py +++ b/erpnext/stock/doctype/item_standard_cost/item_standard_cost.py @@ -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( diff --git a/erpnext/stock/doctype/item_standard_cost/test_item_standard_cost.py b/erpnext/stock/doctype/item_standard_cost/test_item_standard_cost.py index c695694eeca..597b978b17b 100644 --- a/erpnext/stock/doctype/item_standard_cost/test_item_standard_cost.py +++ b/erpnext/stock/doctype/item_standard_cost/test_item_standard_cost.py @@ -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() diff --git a/erpnext/stock/doctype/stock_entry/services/gl_composer.py b/erpnext/stock/doctype/stock_entry/services/gl_composer.py index 4a945876a33..f8b93d765ec 100644 --- a/erpnext/stock/doctype/stock_entry/services/gl_composer.py +++ b/erpnext/stock/doctype/stock_entry/services/gl_composer.py @@ -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: diff --git a/erpnext/stock/services/base_stock_gl_composer.py b/erpnext/stock/services/base_stock_gl_composer.py index 8f9d207d8a3..e81b40963f7 100644 --- a/erpnext/stock/services/base_stock_gl_composer.py +++ b/erpnext/stock/services/base_stock_gl_composer.py @@ -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 = []