diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index ad22e5c3c08..f93d9d301e0 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1447,9 +1447,21 @@ class StockEntry(StockController, SubcontractingInwardController): outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate) has_consumption_basis = self.has_consumption_basis() +<<<<<<< HEAD items = [] # Set basic rate for incoming items for d in self.get("items"): +======= + bom_cost_allocation_per = ( + frappe.get_cached_value("BOM", self.bom_no, "cost_allocation_per") if self.bom_no else None + ) + + secondary_items_cost_basis = self.get_secondary_items_cost_basis(outgoing_items_cost) + + zero_valuation_items = [] + finished_items_last = sorted(self.get("items"), key=lambda row: cint(row.is_finished_item)) + for d in finished_items_last: +>>>>>>> 8db8c6a83d (fix(stock): allocate secondary item cost from the consumption entry (#57738)) if d.s_warehouse or d.set_basic_rate_manually: continue @@ -1459,7 +1471,19 @@ class StockEntry(StockController, SubcontractingInwardController): d.basic_amount = 0.0 continue +<<<<<<< HEAD rate_derived_from_consumption = False +======= + self._set_incoming_item_rate( + d, + outgoing_items_cost, + raise_error_if_no_rate, + zero_valuation_items, + bom_cost_allocation_per, + has_consumption_basis, + secondary_items_cost_basis, + ) +>>>>>>> 8db8c6a83d (fix(stock): allocate secondary item cost from the consumption entry (#57738)) if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer": d.basic_rate = 0.0 @@ -1523,6 +1547,20 @@ class StockEntry(StockController, SubcontractingInwardController): frappe.msgprint(message, alert=True) + def get_secondary_items_cost_basis(self, outgoing_items_cost) -> float: + """The cost a BOM allocation splits: the consumed rows, or the entry that replaced them.""" + if outgoing_items_cost or self.purpose != "Manufacture" or not self.work_order: + return outgoing_items_cost + + settings = frappe.get_single("Manufacturing Settings") + if not (settings.material_consumption and settings.get_rm_cost_from_consumption_entry): + return outgoing_items_cost + + if not self.get_consumption_entries(): + return outgoing_items_cost + + return self._fetch_consumption_entry_cost() + 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")): @@ -1548,6 +1586,77 @@ class StockEntry(StockController, SubcontractingInwardController): ) return self._consumption_entries +<<<<<<< HEAD +======= + 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, + secondary_items_cost_basis=0, + ): + has_derived_rate = 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 + ) + has_derived_rate = 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 + has_derived_rate = any(item.s_warehouse for item in self.get("items")) + + if self.bom_no: + d.basic_rate *= bom_cost_allocation_per / 100 + elif d.secondary_item_type and d.bom_secondary_item: + cost_allocation_per = flt( + frappe.get_value("BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per") + ) + if flt(d.transfer_qty): + d.basic_rate = (secondary_items_cost_basis * (cost_allocation_per / 100)) / d.transfer_qty + has_derived_rate = True + + # A rate of zero that was derived rather than left unset is a real cost. Falling back to + # the item's valuation here would value free inputs, or an unallocated row, as output. + if not d.basic_rate and not d.allow_zero_valuation_rate and not has_derived_rate: + 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, + ) + + # 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")) + + def _notify_zero_valuation_rate(self, items): + if len(items) > 1: + message = _( + "Items rate has been updated to zero as Allow Zero Valuation Rate is checked for the following items: {0}" + ).format(", ".join(frappe.bold(item) for item in items)) + else: + message = _( + "Item rate has been updated to zero as Allow Zero Valuation Rate is checked for item {0}" + ).format(frappe.bold(items[0])) + + frappe.msgprint(message, alert=True) + +>>>>>>> 8db8c6a83d (fix(stock): allocate secondary item cost from the consumption entry (#57738)) 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"): diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index d91ec89d7dd..c24760520b5 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2737,6 +2737,257 @@ class TestStockEntry(ERPNextTestSuite): self.assertEqual(fg_sle.incoming_rate, 0) self.assertEqual(fg_sle.stock_value_difference, 0) +<<<<<<< HEAD +======= + def test_secondary_item_type_does_not_waive_inspection_outside_manufacturing(self): + """A stray secondary item type must not let a QI-required item through a receipt.""" + item = make_item( + properties={ + "is_stock_item": 1, + "valuation_rate": 50, + "inspection_required_before_purchase": 1, + } + ).name + + def receipt(secondary_item_type): + se = frappe.new_doc("Stock Entry") + se.purpose = se.stock_entry_type = "Material Receipt" + se.company = "_Test Company" + se.inspection_required = 1 + se.append( + "items", + { + "item_code": item, + "t_warehouse": "_Test Warehouse - _TC", + "qty": 10, + "conversion_factor": 1, + "secondary_item_type": secondary_item_type, + }, + ) + return se + + self.assertRaises(QualityInspectionRequiredError, receipt("").submit) + self.assertRaises(QualityInspectionRequiredError, receipt("Scrap").submit) + + def test_manufacture_balances_secondary_item_added_without_a_bom(self): + """A secondary item with no BOM link is costed out of the finished good, as legacy scrap was.""" + rm_item = make_item(properties={"is_stock_item": 1}).name + fg_item = make_item(properties={"is_stock_item": 1}).name + scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name + warehouse = "_Test Warehouse - _TC" + + make_stock_entry(item_code=rm_item, target=warehouse, qty=10, basic_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": warehouse, + "qty": 10, + "is_finished_item": 1, + "conversion_factor": 1, + }, + ) + se.append( + "items", + { + "item_code": scrap_item, + "t_warehouse": warehouse, + "qty": 5, + "secondary_item_type": "Scrap", + "conversion_factor": 1, + }, + ) + se.save() + + scrap_row = se.items[2] + self.assertEqual(flt(scrap_row.basic_rate), 20.0) + self.assertEqual(flt(scrap_row.basic_amount), 100.0) + + fg_row = se.items[1] + self.assertEqual(flt(fg_row.basic_rate), 90.0) + self.assertEqual(flt(fg_row.basic_amount), 900.0) + + self.assertEqual(flt(se.total_incoming_value), 1000.0) + self.assertEqual(flt(se.total_outgoing_value), 1000.0) + self.assertEqual(flt(se.value_difference), 0.0) + + def test_repack_allocates_cost_to_secondary_item(self): + """A Repack secondary item takes its own BOM share, not the finished good's.""" + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name + fg_item = make_item(properties={"is_stock_item": 1}).name + scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name + warehouse = "_Test Warehouse - _TC" + + bom = frappe.get_doc( + { + "doctype": "BOM", + "item": fg_item, + "currency": "INR", + "quantity": 10, + "company": "_Test Company", + } + ) + bom.append("items", {"item_code": rm_item, "qty": 10}) + bom.append( + "secondary_items", + { + "secondary_item_type": "Scrap", + "item_code": scrap_item, + "item_name": scrap_item, + "qty": 5, + "cost_allocation_per": 25, + "process_loss_per": 0, + }, + ) + bom.insert() + bom.submit() + self.assertEqual(flt(bom.cost_allocation_per), 75.0) + + make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100) + + se = frappe.new_doc("Stock Entry") + se.purpose = se.stock_entry_type = "Repack" + se.company = "_Test Company" + se.from_bom = 1 + se.bom_no = bom.name + se.fg_completed_qty = 10 + se.from_warehouse = warehouse + se.to_warehouse = warehouse + se.get_items() + se.save() + + fg_row = next(d for d in se.items if d.is_finished_item) + scrap_row = next(d for d in se.items if d.secondary_item_type) + + self.assertFalse(scrap_row.is_finished_item) + self.assertEqual(flt(scrap_row.basic_amount), 250.0) + self.assertEqual(flt(fg_row.basic_amount), 750.0) + + self.assertEqual(flt(se.total_incoming_value), 1000.0) + self.assertEqual(flt(se.total_outgoing_value), 1000.0) + self.assertEqual(flt(se.value_difference), 0.0) + + def test_secondary_item_with_zero_cost_allocation_carries_no_value(self): + """A BOM that allocates 0% to a secondary item gives the finished good everything.""" + 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, + ) + + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name + fg_item = make_item(properties={"is_stock_item": 1}).name + scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name + warehouse = "_Test Warehouse - _TC" + + bom = frappe.get_doc( + { + "doctype": "BOM", + "item": fg_item, + "currency": "INR", + "quantity": 10, + "company": "_Test Company", + } + ) + bom.append("items", {"item_code": rm_item, "qty": 10}) + bom.append( + "secondary_items", + { + "secondary_item_type": "Scrap", + "item_code": scrap_item, + "item_name": scrap_item, + "qty": 5, + "cost_allocation_per": 0, + "process_loss_per": 0, + }, + ) + bom.insert() + bom.submit() + self.assertEqual(flt(bom.cost_allocation_per), 100.0) + + make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100) + wo = make_wo_order_test_record( + production_item=fg_item, bom_no=bom.name, qty=10, skip_transfer=1, source_warehouse=warehouse + ) + + se = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10)) + se.save() + + scrap_row = next(d for d in se.items if d.secondary_item_type) + fg_row = next(d for d in se.items if d.is_finished_item) + + self.assertEqual(flt(scrap_row.basic_rate), 0.0) + self.assertEqual(flt(scrap_row.basic_amount), 0.0) + self.assertEqual(flt(fg_row.basic_amount), 1000.0) + self.assertEqual(flt(se.value_difference), 0.0) + + @ERPNextTestSuite.change_settings( + "Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1} + ) + def test_secondary_item_allocation_uses_consumption_entry_cost(self): + """A BOM allocation splits the consumption entry's cost, not an empty set of consumed rows.""" + 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, + ) + + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name + fg_item = make_item(properties={"is_stock_item": 1}).name + scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name + warehouse = "_Test Warehouse - _TC" + + bom = frappe.get_doc( + { + "doctype": "BOM", + "item": fg_item, + "currency": "INR", + "quantity": 10, + "company": "_Test Company", + } + ) + bom.append("items", {"item_code": rm_item, "qty": 10}) + bom.append( + "secondary_items", + { + "secondary_item_type": "Scrap", + "item_code": scrap_item, + "item_name": scrap_item, + "qty": 5, + "cost_allocation_per": 25, + "process_loss_per": 0, + }, + ) + bom.insert() + bom.submit() + + make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100) + wo = make_wo_order_test_record( + production_item=fg_item, bom_no=bom.name, qty=10, skip_transfer=1, source_warehouse=warehouse + ) + + consumption = frappe.get_doc( + make_stock_entry_from_wo(wo.name, "Material Consumption for Manufacture", 10) + ) + consumption.submit() + self.assertEqual(flt(consumption.total_outgoing_value), 1000.0) + + se = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10)) + se.save() + + scrap_row = next(d for d in se.items if d.secondary_item_type) + fg_row = next(d for d in se.items if d.is_finished_item) + + self.assertEqual(flt(fg_row.basic_amount), 750.0) + self.assertEqual(flt(scrap_row.basic_amount), 250.0) + self.assertEqual(flt(se.total_incoming_value), 1000.0) + +>>>>>>> 8db8c6a83d (fix(stock): allocate secondary item cost from the consumption entry (#57738)) 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 (