mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-06 03:03:04 +00:00
fix(stock): keep manufactured item rate at zero when inputs are free (#57334)
* fix(stock): keep manufactured item rate at zero when inputs are free
when a finished item is produced from raw materials consumed at zero
valuation, the incoming rate fell back to the item's own valuation
rate (or BOM cost), valuing free inputs as output and inflating the fg
value on every production run.
add has_consumption_basis() to detect when the consumed cost is known
even if it is zero (consumed rows present, or a consumption entry
exists for the work order). when it is, skip the get_valuation_rate and
BOM-cost fallbacks so a real cost of zero is preserved.
* test(stock): cover manufacture rate for zero-valued raw materials
- manufacture from a free input keeps fg basic_rate and sle
incoming_rate/stock_value_difference at zero even when the fg already
carries a valuation in the target warehouse
- material consumption on with no consumption entry does not fall back
to bom/price-list rate for free inputs
- zero-valued consumption entry keeps the manufacture entry's fg rate
at zero
(cherry picked from commit 73224d3650)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
This commit is contained in:
@@ -1443,6 +1443,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
"""
|
||||
# Set rate for outgoing items
|
||||
outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate)
|
||||
has_consumption_basis = self.has_consumption_basis()
|
||||
|
||||
items = []
|
||||
# Set basic rate for incoming items
|
||||
@@ -1456,16 +1457,21 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
d.basic_amount = 0.0
|
||||
continue
|
||||
|
||||
rate_derived_from_consumption = False
|
||||
|
||||
if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer":
|
||||
d.basic_rate = 0.0
|
||||
items.append(d.item_code)
|
||||
elif d.is_finished_item:
|
||||
if self.purpose == "Manufacture":
|
||||
d.basic_rate = self.get_basic_rate_for_manufactured_item(
|
||||
d.transfer_qty, outgoing_items_cost
|
||||
d.transfer_qty, outgoing_items_cost, has_consumption_basis
|
||||
)
|
||||
rate_derived_from_consumption = has_consumption_basis
|
||||
elif self.purpose == "Repack":
|
||||
d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost)
|
||||
# Repack rate comes from consumed source-warehouse rows, not consumption entries
|
||||
rate_derived_from_consumption = any(item.s_warehouse for item in self.get("items"))
|
||||
|
||||
if self.bom_no:
|
||||
d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100
|
||||
@@ -1478,7 +1484,9 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
if cost_allocation_per and flt(d.transfer_qty):
|
||||
d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty
|
||||
|
||||
if not d.basic_rate and not d.allow_zero_valuation_rate:
|
||||
# A rate of zero derived from the consumed items is their actual cost, not a missing
|
||||
# rate. Falling back to the item's valuation here would value free inputs as output.
|
||||
if not d.basic_rate and not d.allow_zero_valuation_rate and not rate_derived_from_consumption:
|
||||
if self.is_new():
|
||||
raise_error_if_no_rate = False
|
||||
|
||||
@@ -1513,6 +1521,31 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
|
||||
frappe.msgprint(message, alert=True)
|
||||
|
||||
def has_consumption_basis(self) -> bool:
|
||||
"""Whether the cost of the consumed items is known, even when that cost is zero."""
|
||||
if any(d.s_warehouse for d in self.get("items")):
|
||||
return True
|
||||
|
||||
settings = frappe.get_single("Manufacturing Settings")
|
||||
if settings.material_consumption and settings.get_rm_cost_from_consumption_entry and self.work_order:
|
||||
return bool(self.get_consumption_entries())
|
||||
|
||||
return False
|
||||
|
||||
def get_consumption_entries(self) -> list[str]:
|
||||
# Cached: queried in both has_consumption_basis() and get_basic_rate_for_manufactured_item()
|
||||
if getattr(self, "_consumption_entries", None) is None:
|
||||
self._consumption_entries = frappe.get_all(
|
||||
"Stock Entry",
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"work_order": self.work_order,
|
||||
"purpose": "Material Consumption for Manufacture",
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
return self._consumption_entries
|
||||
|
||||
def set_rate_for_outgoing_items(self, reset_outgoing_rate=True, raise_error_if_no_rate=True):
|
||||
outgoing_items_cost = 0.0
|
||||
for d in self.get("items"):
|
||||
@@ -1566,21 +1599,16 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
)
|
||||
return flt(outgoing_items_cost / total_fg_qty)
|
||||
|
||||
def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float:
|
||||
def get_basic_rate_for_manufactured_item(
|
||||
self, finished_item_qty, outgoing_items_cost=0, has_consumption_basis=False
|
||||
) -> float:
|
||||
settings = frappe.get_single("Manufacturing Settings")
|
||||
scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_legacy_scrap_item])
|
||||
|
||||
if settings.material_consumption:
|
||||
if settings.get_rm_cost_from_consumption_entry and self.work_order:
|
||||
# Validate only if Material Consumption Entry exists for the Work Order.
|
||||
if frappe.db.exists(
|
||||
"Stock Entry",
|
||||
{
|
||||
"docstatus": 1,
|
||||
"work_order": self.work_order,
|
||||
"purpose": "Material Consumption for Manufacture",
|
||||
},
|
||||
):
|
||||
if self.get_consumption_entries():
|
||||
for item in self.items:
|
||||
if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item:
|
||||
label = frappe.get_meta(settings.doctype).get_label(
|
||||
@@ -1627,7 +1655,9 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
)
|
||||
).run()[0][0] or 0
|
||||
|
||||
elif not outgoing_items_cost:
|
||||
# Estimate from the BOM only when nothing was consumed. A consumed cost of zero is a
|
||||
# real cost, so substituting BOM rates would value free inputs as output.
|
||||
elif not outgoing_items_cost and not has_consumption_basis:
|
||||
bom_items = self.get_bom_raw_materials(finished_item_qty)
|
||||
outgoing_items_cost = sum([flt(row.qty) * flt(row.rate) for row in bom_items.values()])
|
||||
|
||||
|
||||
@@ -2654,6 +2654,149 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
se.save()
|
||||
se.submit()
|
||||
|
||||
def test_manufacture_with_zero_valued_raw_material(self):
|
||||
# A finished good produced from free inputs is worth nothing. Falling back to the item's
|
||||
# own valuation would create value out of nothing and inflate it on every production run.
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
fg_warehouse = "Finished Goods - _TC"
|
||||
|
||||
rm_receipt = make_stock_entry(item_code=rm_item, target=warehouse, qty=100, rate=0, do_not_save=True)
|
||||
rm_receipt.items[0].allow_zero_valuation_rate = 1
|
||||
rm_receipt.save()
|
||||
rm_receipt.submit()
|
||||
|
||||
# the finished good already carries a valuation in the target warehouse
|
||||
make_stock_entry(item_code=fg_item, target=fg_warehouse, qty=10, rate=100)
|
||||
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.purpose = se.stock_entry_type = "Manufacture"
|
||||
se.company = "_Test Company"
|
||||
se.append(
|
||||
"items",
|
||||
{"item_code": rm_item, "s_warehouse": warehouse, "qty": 10, "conversion_factor": 1},
|
||||
)
|
||||
se.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": fg_item,
|
||||
"t_warehouse": fg_warehouse,
|
||||
"qty": 10,
|
||||
"is_finished_item": 1,
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
se.save()
|
||||
|
||||
self.assertEqual(se.items[0].basic_amount, 0)
|
||||
self.assertEqual(se.items[1].basic_rate, 0)
|
||||
self.assertEqual(se.items[1].basic_amount, 0)
|
||||
|
||||
se.submit()
|
||||
|
||||
fg_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": se.name, "item_code": fg_item, "is_cancelled": 0},
|
||||
["incoming_rate", "stock_value_difference"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
self.assertEqual(fg_sle.incoming_rate, 0)
|
||||
self.assertEqual(fg_sle.stock_value_difference, 0)
|
||||
|
||||
def _make_wo_for_free_raw_material(self, rm_item, fg_item, bom_no):
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_from_wo,
|
||||
)
|
||||
|
||||
receipt = make_stock_entry(item_code=rm_item, target="Stores - _TC", qty=10, rate=0, do_not_save=True)
|
||||
receipt.items[0].allow_zero_valuation_rate = 1
|
||||
receipt.save()
|
||||
receipt.submit()
|
||||
|
||||
wo = make_wo_order_test_record(production_item=fg_item, bom_no=bom_no, qty=10)
|
||||
|
||||
transfer = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Material Transfer for Manufacture", 10))
|
||||
transfer.items[0].s_warehouse = "Stores - _TC"
|
||||
transfer.insert().submit()
|
||||
|
||||
return wo
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 0}
|
||||
)
|
||||
def test_manufacture_does_not_fall_back_to_bom_cost_for_free_raw_material(self):
|
||||
# The BOM is only an estimate for when nothing was consumed. Items that were consumed and
|
||||
# cost nothing are a real cost, so a BOM rate must not stand in for them.
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_from_wo,
|
||||
)
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Price",
|
||||
"item_code": rm_item,
|
||||
"price_list": "_Test Price List India",
|
||||
"price_list_rate": 150,
|
||||
"buying": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
# price the BOM off the price list so that it carries a rate the free stock does not
|
||||
bom = make_bom(item=fg_item, raw_materials=[rm_item], do_not_save=True)
|
||||
bom.rm_cost_as_per = "Price List"
|
||||
bom.buying_price_list = "_Test Price List India"
|
||||
bom.currency = "INR"
|
||||
bom.save()
|
||||
bom.submit()
|
||||
|
||||
wo = self._make_wo_for_free_raw_material(rm_item, fg_item, bom.name)
|
||||
|
||||
manufacture = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10))
|
||||
manufacture.save()
|
||||
|
||||
fg_row = next(d for d in manufacture.items if d.is_finished_item)
|
||||
self.assertEqual(fg_row.basic_rate, 0)
|
||||
self.assertEqual(fg_row.basic_amount, 0)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1}
|
||||
)
|
||||
def test_manufacture_with_zero_valued_consumption_entry(self):
|
||||
# The raw material is consumed by a separate entry, so the Manufacture entry carries no
|
||||
# consumed rows of its own. Its cost is still known, and it is zero.
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_from_wo,
|
||||
)
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
# the finished good already carries a valuation in the work order's target warehouse
|
||||
make_stock_entry(item_code=fg_item, target="_Test Warehouse 1 - _TC", qty=10, rate=100)
|
||||
|
||||
bom = make_bom(item=fg_item, raw_materials=[rm_item]).name
|
||||
wo = self._make_wo_for_free_raw_material(rm_item, fg_item, bom)
|
||||
|
||||
consumption = frappe.get_doc(
|
||||
make_stock_entry_from_wo(wo.name, "Material Consumption for Manufacture", 10)
|
||||
)
|
||||
consumption.insert().submit()
|
||||
|
||||
manufacture = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10))
|
||||
manufacture.save()
|
||||
|
||||
fg_row = next(d for d in manufacture.items if d.is_finished_item)
|
||||
self.assertEqual(fg_row.basic_rate, 0)
|
||||
self.assertEqual(fg_row.basic_amount, 0)
|
||||
|
||||
def test_disassemble_entry_without_wo(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
|
||||
|
||||
Reference in New Issue
Block a user