diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index d601dc093b8..e1ae4edcb10 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1320,7 +1320,12 @@ class StockEntry(StockController): """ # Set rate for outgoing items outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate) +<<<<<<< HEAD finished_item_qty = sum(d.transfer_qty for d in self.items if d.is_finished_item) +======= + 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 @@ -1332,6 +1337,7 @@ class StockEntry(StockController): d.basic_rate = 0.0 items.append(d.item_code) +<<<<<<< HEAD elif d.is_finished_item: if self.purpose == "Manufacture": d.basic_rate = self.get_basic_rate_for_manufactured_item( @@ -1339,11 +1345,22 @@ class StockEntry(StockController): ) 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 not d.basic_rate and not d.allow_zero_valuation_rate: if self.is_new(): raise_error_if_no_rate = False +<<<<<<< HEAD d.basic_rate = get_valuation_rate( d.item_code, d.t_warehouse, @@ -1356,13 +1373,82 @@ class StockEntry(StockController): batch_no=d.batch_no, serial_and_batch_bundle=d.serial_and_batch_bundle, ) +======= + 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)) # 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")) +<<<<<<< HEAD if items: message = "" +======= + # 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 len(items) > 1: message = _( @@ -1428,11 +1514,14 @@ class StockEntry(StockController): ) 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_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( @@ -1495,6 +1584,77 @@ class StockEntry(StockController): 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 b344b772cbb..08d780645d1 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2574,6 +2574,179 @@ class TestStockEntry(FrappeTestCase): material_request.reload() self.assertEqual(material_request.transfer_status, "Completed") +<<<<<<< HEAD +======= + def test_manufacture_entry_without_wo(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + fg_item = make_item("_Mobiles", properties={"is_stock_item": 1}).name + rm_item1 = make_item("_Temper Glass", properties={"is_stock_item": 1}).name + rm_item2 = make_item("_Battery", properties={"is_stock_item": 1}).name + warehouse = "_Test Warehouse - _TC" + make_stock_entry(item_code=rm_item1, target=warehouse, qty=5, purpose="Material Receipt") + make_stock_entry(item_code=rm_item2, target=warehouse, qty=5, purpose="Material Receipt") + + bom_no = make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2]).name + se = make_stock_entry(item_code=fg_item, qty=1, purpose="Repack", do_not_save=True) + se.from_bom = 1 + se.use_multi_level_bom = 1 + se.bom_no = bom_no + se.fg_completed_qty = 1 + se.from_warehouse = warehouse + se.to_warehouse = warehouse + + se.get_items() + rm_items = {d.item_code: d.qty for d in se.items if d.item_code != fg_item} + self.assertEqual(rm_items[rm_item1], 1) + self.assertEqual(rm_items[rm_item2], 1) + se.calculate_rate_and_amount() + 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) + +>>>>>>> 73224d3650 (fix(stock): keep manufactured item rate at zero when inputs are free (#57334)) def test_disassemble_entry_without_wo(self): from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom