From 73fc4a64ed779a7a7d834b91d0b06b1bf5880155 Mon Sep 17 00:00:00 2001 From: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:35:00 +0530 Subject: [PATCH] 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 73224d3650f5b6df7c9380dfd294ad85b8469000) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.py | 162 +++++++++++++++++- .../doctype/stock_entry/test_stock_entry.py | 143 ++++++++++++++++ 2 files changed, 304 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e050429eb98..7b023962d58 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1441,6 +1441,11 @@ 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) +<<<<<<< HEAD +======= + raise_error_if_no_rate = raise_error_if_no_rate and not self.is_new() + has_consumption_basis = self.has_consumption_basis() +>>>>>>> 73224d3650 (fix(stock): keep manufactured item rate at zero when inputs are free (#57334)) items = [] # Set basic rate for incoming items @@ -1454,6 +1459,7 @@ class StockEntry(StockController, SubcontractingInwardController): d.basic_amount = 0.0 continue +<<<<<<< HEAD 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) @@ -1464,6 +1470,16 @@ class StockEntry(StockController, SubcontractingInwardController): ) elif self.purpose == "Repack": d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost) +======= + self._set_incoming_item_rate( + d, + outgoing_items_cost, + raise_error_if_no_rate, + zero_valuation_items, + bom_cost_allocation_per, + has_consumption_basis, + ) +>>>>>>> 73224d3650 (fix(stock): keep manufactured item rate at zero when inputs are free (#57334)) if self.bom_no: d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100 @@ -1476,9 +1492,61 @@ 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 +<<<<<<< HEAD if not d.basic_rate and not d.allow_zero_valuation_rate: if self.is_new(): raise_error_if_no_rate = False +======= + 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_rm_cost_for_manufacture() + 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_incoming_item_rate( + self, + d, + outgoing_items_cost, + raise_error_if_no_rate, + zero_valuation_items, + bom_cost_allocation_per=None, + has_consumption_basis=False, + ): + 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 + zero_valuation_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, 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")) +>>>>>>> 73224d3650 (fix(stock): keep manufactured item rate at zero when inputs are free (#57334)) d.basic_rate = get_valuation_rate( d.item_code, @@ -1493,9 +1561,27 @@ class StockEntry(StockController, SubcontractingInwardController): serial_and_batch_bundle=d.serial_and_batch_bundle, ) +<<<<<<< HEAD # do not round off basic rate to avoid precision loss d.basic_rate = flt(d.basic_rate) d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) +======= + # 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: + d.basic_rate = get_valuation_rate( + d.item_code, + d.t_warehouse, + self.doctype, + self.name, + d.allow_zero_valuation_rate, + currency=erpnext.get_company_currency(self.company), + company=self.company, + raise_error_if_no_rate=raise_error_if_no_rate, + batch_no=d.batch_no, + serial_and_batch_bundle=d.serial_and_batch_bundle, + ) +>>>>>>> 73224d3650 (fix(stock): keep manufactured item rate at zero when inputs are free (#57334)) if items: message = "" @@ -1564,11 +1650,14 @@ 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: +<<<<<<< HEAD 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( @@ -1631,6 +1720,77 @@ class StockEntry(StockController, SubcontractingInwardController): return flt((outgoing_items_cost - scrap_items_cost) / finished_item_qty) +======= + outgoing_items_cost = self._get_rm_cost_for_manufacture( + settings, finished_item_qty, outgoing_items_cost, has_consumption_basis + ) + + return flt((outgoing_items_cost - scrap_items_cost) / finished_item_qty) + + def _get_rm_cost_for_manufacture( + self, settings, finished_item_qty, outgoing_items_cost, has_consumption_basis=False + ): + if settings.get_rm_cost_from_consumption_entry and self.work_order: + if self.get_consumption_entries(): + self._validate_no_raw_materials_in_manufacture_entry(settings) + self._validate_single_manufacture_entry() + return self._fetch_consumption_entry_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()]) + + return outgoing_items_cost + + def _validate_no_raw_materials_in_manufacture_entry(self, settings): + for item in self.items: + if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item: + label = frappe.get_meta(settings.doctype).get_label("get_rm_cost_from_consumption_entry") + frappe.throw( + _( + "Row {0}: As {1} is enabled, raw materials cannot be added to {2} entry. Use {3} entry to consume raw materials." + ).format( + item.idx, + frappe.bold(label), + frappe.bold(_("Manufacture")), + frappe.bold(_("Material Consumption for Manufacture")), + ) + ) + + def _validate_single_manufacture_entry(self): + if frappe.db.exists( + "Stock Entry", + { + "docstatus": 1, + "work_order": self.work_order, + "purpose": "Manufacture", + "name": ("!=", self.name), + }, + ): + frappe.throw( + _("Only one {0} entry can be created against the Work Order {1}").format( + frappe.bold(_("Manufacture")), frappe.bold(self.work_order) + ) + ) + + def _fetch_consumption_entry_cost(self): + SE = frappe.qb.DocType("Stock Entry") + SE_ITEM = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(SE) + .left_join(SE_ITEM) + .on(SE.name == SE_ITEM.parent) + .select(Sum(SE_ITEM.valuation_rate * SE_ITEM.transfer_qty)) + .where( + (SE.docstatus == 1) + & (SE.work_order == self.work_order) + & (SE.purpose == "Material Consumption for Manufacture") + ) + ).run()[0][0] or 0 + +>>>>>>> 73224d3650 (fix(stock): keep manufactured item rate at zero when inputs are free (#57334)) def distribute_additional_costs(self): # If no incoming items, set additional costs blank if not any(d.item_code for d in self.items if d.t_warehouse): diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index ee3c7886b17..6c341b62f27 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -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