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.
This commit is contained in:
Sudharsanan Ashok
2026-09-08 16:13:12 +05:30
committed by GitHub
parent 79fc039092
commit 1728d1b0f5
3 changed files with 209 additions and 34 deletions

View File

@@ -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

View File

@@ -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"):

View File

@@ -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",