From 1728d1b0f584f5a29fe790a7fdcad424a9e3972b Mon Sep 17 00:00:00 2001 From: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:13:12 +0530 Subject: [PATCH] fix(stock): distribute additional costs when incoming items have no value (#58842) * fix(stock): distribute additional costs when incoming items have no value when every incoming row has zero basic amount, for example a raw material purchased at zero rate, distribute_additional_costs returned early and left additional_cost at 0 on the finished item. the charges were never capitalised into valuation and the balancing debit stayed in stock adjustment instead of reaching stock in hand. the gl composer had its own quantity fallback for the same case, but it divided by the qty of every row rather than the incoming ones, so a manufacture entry booked only a fraction of the cost to the expense account, and it apportioned by qty while valuation apportions by stock qty, which split rows of differing conversion factors two different ways. both sides now take the rows, the basis and its total from a single get_additional_cost_allocation, falling back to transfer_qty so valuation and gl cannot disagree. the basis is unchanged whenever the incoming rows carry value. * test(stock): cover additional cost distribution for zero valued items adds qty based distribution cases for manufacture and material receipt, a manufacture entry asserting the whole cost reaches its expense account, and a conversion factor case asserting the gl split matches the valuation split. updates test_total_basic_amount_zero, which asserted the cost landing in stock adjustment rather than being capitalised. --- .../stock_entry/services/gl_composer.py | 27 +-- .../stock/doctype/stock_entry/stock_entry.py | 34 ++-- .../doctype/stock_entry/test_stock_entry.py | 182 +++++++++++++++++- 3 files changed, 209 insertions(+), 34 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/services/gl_composer.py b/erpnext/stock/doctype/stock_entry/services/gl_composer.py index 0a9cbba0b0b..8920956f5f1 100644 --- a/erpnext/stock/doctype/stock_entry/services/gl_composer.py +++ b/erpnext/stock/doctype/stock_entry/services/gl_composer.py @@ -26,17 +26,10 @@ class StockEntryGLComposer(BaseStockGLComposer): doc = self.doc gl_entries = super().compose(inventory_account_map) - if doc.purpose in ("Repack", "Manufacture"): - total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.is_finished_item) - else: - total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.t_warehouse) - - divide_based_on = total_basic_amount - if doc.get("additional_costs") and not total_basic_amount: - divide_based_on = sum(item.qty for item in doc.get("items")) + incoming_items, basis, divide_based_on = doc.get_additional_cost_allocation() item_account_wise_additional_cost = self._build_additional_cost_per_item_account( - total_basic_amount, divide_based_on + incoming_items, basis, divide_based_on ) if item_account_wise_additional_cost: self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost) @@ -183,24 +176,20 @@ class StockEntryGLComposer(BaseStockGLComposer): ) def _build_additional_cost_per_item_account( - self, total_basic_amount: float, divide_based_on: float + self, incoming_items: list, basis: str, divide_based_on: float ) -> dict: - doc = self.doc item_account_wise_additional_cost = {} + if not divide_based_on: + return item_account_wise_additional_cost - for t in doc.get("additional_costs"): - for d in doc.get("items"): - if doc.purpose in ("Repack", "Manufacture") and not d.is_finished_item: - continue - elif not d.t_warehouse: - continue - + for t in self.doc.get("additional_costs"): + for d in incoming_items: item_account_wise_additional_cost.setdefault((d.item_code, d.name), {}) item_account_wise_additional_cost[(d.item_code, d.name)].setdefault( t.expense_account, {"amount": 0.0, "base_amount": 0.0} ) - multiply_based_on = d.basic_amount if total_basic_amount else d.qty + multiply_based_on = flt(d.get(basis)) entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account] entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index a4e727bb8e4..9c234bd9d30 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -920,22 +920,28 @@ class StockEntry(StockController, SubcontractingInwardController): self.total_additional_costs = sum(flt(t.base_amount) for t in self.get("additional_costs")) - if self.purpose in ("Repack", "Manufacture"): - incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item) - else: - incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse) - - if not incoming_items_cost: - return + incoming_items, basis, total_basis = self.get_additional_cost_allocation() for d in self.get("items"): - if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item: - d.additional_cost = 0 - continue - elif not d.t_warehouse: - d.additional_cost = 0 - continue - d.additional_cost = (flt(d.basic_amount) / incoming_items_cost) * self.total_additional_costs + d.additional_cost = 0 + + if not total_basis: + return + + for d in incoming_items: + d.additional_cost = (flt(d.get(basis)) / total_basis) * self.total_additional_costs + + def get_additional_cost_allocation(self): + if self.purpose in ("Repack", "Manufacture"): + incoming_items = [d for d in self.get("items") if d.is_finished_item] + else: + incoming_items = [d for d in self.get("items") if d.t_warehouse] + + total_basic_amount = sum(flt(d.basic_amount) for d in incoming_items) + if total_basic_amount: + return incoming_items, "basic_amount", total_basic_amount + + return incoming_items, "transfer_qty", sum(flt(d.transfer_qty) for d in incoming_items) def update_valuation_rate(self, reset_outgoing_rate=True): for d in self.get("items"): diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index bc3f9ae9fbd..b96b9750ef0 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2233,10 +2233,12 @@ class TestStockEntry(ERPNextTestSuite): se.insert() se.submit() + self.assertEqual([33.33, 66.67], [flt(d.additional_cost, 2) for d in se.items]) + self.check_gl_entries( "Stock Entry", se.name, - sorted([["Stock Adjustment - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]), + sorted([["Stock In Hand - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]), ) def test_conversion_factor_change(self): @@ -2282,6 +2284,184 @@ class TestStockEntry(ERPNextTestSuite): distributed_costs = [d.additional_cost for d in se.items] self.assertEqual([0.0, 100.0, 0.0], distributed_costs) + def test_additional_cost_distribution_manufacture_zero_valued_items(self): + se = frappe.get_doc( + doctype="Stock Entry", + purpose="Manufacture", + additional_costs=[frappe._dict(base_amount=100)], + items=[ + frappe._dict(item_code="RM", basic_amount=0, transfer_qty=10), + frappe._dict( + item_code="FG", basic_amount=0, transfer_qty=5, t_warehouse="X", is_finished_item=1 + ), + frappe._dict(item_code="scrap", basic_amount=0, transfer_qty=2, t_warehouse="X"), + ], + ) + + se.distribute_additional_costs() + + distributed_costs = [d.additional_cost for d in se.items] + self.assertEqual([0.0, 100.0, 0.0], distributed_costs) + + def test_additional_cost_distribution_zero_valued_items(self): + se = frappe.get_doc( + doctype="Stock Entry", + purpose="Material Receipt", + additional_costs=[frappe._dict(base_amount=100)], + items=[ + frappe._dict(item_code="RECEIVED_1", basic_amount=0, transfer_qty=20, t_warehouse="X"), + frappe._dict(item_code="RECEIVED_2", basic_amount=0, transfer_qty=30, t_warehouse="X"), + ], + ) + + se.distribute_additional_costs() + + distributed_costs = [d.additional_cost for d in se.items] + self.assertEqual([40.0, 60.0], distributed_costs) + + def test_additional_cost_gl_for_zero_valued_manufacture(self): + company = "_Test Company with perpetual inventory" + rm = make_item("_Test Zero Rate RM", {"is_stock_item": 1}).name + fg = make_item("_Test Zero Rate FG", {"is_stock_item": 1}).name + + receipt = frappe.get_doc( + { + "doctype": "Stock Entry", + "purpose": "Material Receipt", + "stock_entry_type": "Material Receipt", + "posting_date": nowdate(), + "company": company, + "items": [ + { + "item_code": rm, + "qty": 5, + "basic_rate": 0, + "uom": "Nos", + "t_warehouse": "Stores - TCP1", + "allow_zero_valuation_rate": 1, + "cost_center": "Main - TCP1", + } + ], + } + ) + receipt.insert() + receipt.submit() + + se = frappe.get_doc( + { + "doctype": "Stock Entry", + "purpose": "Manufacture", + "stock_entry_type": "Manufacture", + "posting_date": nowdate(), + "company": company, + "items": [ + { + "item_code": rm, + "qty": 5, + "uom": "Nos", + "s_warehouse": "Stores - TCP1", + "cost_center": "Main - TCP1", + }, + { + "item_code": fg, + "qty": 5, + "uom": "Nos", + "t_warehouse": "Finished Goods - TCP1", + "is_finished_item": 1, + "cost_center": "Main - TCP1", + }, + ], + "additional_costs": [ + { + "expense_account": "Miscellaneous Expenses - TCP1", + "amount": 500, + "description": "freight", + } + ], + } + ) + se.insert() + se.submit() + + self.assertEqual(500.0, se.items[1].additional_cost) + self.check_gl_entries( + "Stock Entry", + se.name, + sorted([["Stock In Hand - TCP1", 500.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 500.0]]), + ) + + def test_additional_cost_gl_matches_valuation_split(self): + company = "_Test Company with perpetual inventory" + cost_center = "_Test Additional Cost CC - TCP1" + if not frappe.db.exists("Cost Center", cost_center): + frappe.get_doc( + { + "doctype": "Cost Center", + "cost_center_name": "_Test Additional Cost CC", + "company": company, + "is_group": 0, + "parent_cost_center": "_Test Company with perpetual inventory - TCP1", + } + ).insert() + + uoms = [{"uom": "Nos", "conversion_factor": 1}, {"uom": "Box", "conversion_factor": 2}] + item_a = make_item("_Test Addl Cost CF A", {"is_stock_item": 1, "uoms": uoms}).name + uoms[1]["conversion_factor"] = 3 + item_b = make_item("_Test Addl Cost CF B", {"is_stock_item": 1, "uoms": uoms}).name + + se = frappe.get_doc( + { + "doctype": "Stock Entry", + "purpose": "Material Receipt", + "stock_entry_type": "Material Receipt", + "posting_date": nowdate(), + "company": company, + "items": [ + { + "item_code": item_a, + "qty": 1, + "basic_rate": 0, + "uom": "Box", + "conversion_factor": 2, + "t_warehouse": "Stores - TCP1", + "allow_zero_valuation_rate": 1, + "cost_center": "Main - TCP1", + }, + { + "item_code": item_b, + "qty": 1, + "basic_rate": 0, + "uom": "Box", + "conversion_factor": 3, + "t_warehouse": "Stores - TCP1", + "allow_zero_valuation_rate": 1, + "cost_center": cost_center, + }, + ], + "additional_costs": [ + { + "expense_account": "Miscellaneous Expenses - TCP1", + "amount": 100, + "description": "misc", + } + ], + } + ) + se.insert() + se.submit() + + self.assertEqual([40.0, 60.0], [flt(d.additional_cost, 2) for d in se.items]) + + expense_by_cost_center = frappe.get_all( + "GL Entry", + filters={"voucher_no": se.name, "account": "Miscellaneous Expenses - TCP1"}, + fields=["cost_center", "credit"], + ) + self.assertEqual( + {"Main - TCP1": 40.0, cost_center: 60.0}, + {d.cost_center: d.credit for d in expense_by_cost_center}, + ) + def test_additional_cost_distribution_non_manufacture(self): se = frappe.get_doc( doctype="Stock Entry",