mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-17 08:28:44 +00:00
fix(stock): treat a 0% BOM cost allocation as no cost (#57736)
* fix(stock): treat a 0% BOM cost allocation as no cost
A BOM splits its raw material cost between the finished good and its
secondary items, and validate_total_cost_allocation holds the two to
100%. An allocation of 0% therefore means the finished good takes
everything and the secondary item carries no cost.
The code read it as no allocation at all. A cost_allocation_per of 0 is
falsy, so the branch was skipped, the row kept a rate of zero, and the
fallback below handed it the item's own valuation rate. Producing 1000 of
raw material into a finished good at 100% and scrap at 0% booked 1000 to
the finished good and another 100 to the scrap.
Apply the BOM's share whatever it is, and mark the rate as derived so the
valuation fallback leaves a deliberate zero alone. rate_derived_from_consumption
becomes has_derived_rate, since it now guards more than the consumption case.
* test(stock): cover a secondary item allocated 0% of the cost
The finished good takes the full 1000 and the scrap row is worth nothing.
(cherry picked from commit 7d901ed92c)
# Conflicts:
# erpnext/stock/doctype/stock_entry/stock_entry.py
# erpnext/stock/doctype/stock_entry/test_stock_entry.py
This commit is contained in:
@@ -1548,6 +1548,76 @@ 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,
|
||||
):
|
||||
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 = (outgoing_items_cost * (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)
|
||||
|
||||
>>>>>>> 7d901ed92c (fix(stock): treat a 0% BOM cost allocation as no cost (#57736))
|
||||
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"):
|
||||
|
||||
@@ -2737,6 +2737,197 @@ 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)
|
||||
|
||||
>>>>>>> 7d901ed92c (fix(stock): treat a 0% BOM cost allocation as no cost (#57736))
|
||||
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 (
|
||||
|
||||
Reference in New Issue
Block a user