Compare commits

..

1 Commits

Author SHA1 Message Date
Mihir Kandoi
78b4a08719 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
2026-08-03 10:42:51 +00:00
46 changed files with 66081 additions and 150284 deletions

View File

@@ -567,7 +567,7 @@ $.extend(erpnext.journal_entry, {
lock_reversal_entry: function (frm) {
frm.fields
.filter((field) => field.has_input)
.filter((field) => !["posting_date", "custom_remark", "remark"].includes(field.df.fieldname))
.filter((field) => field.df.fieldname != "posting_date")
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},

View File

@@ -467,24 +467,19 @@ def get_child_docs(doc: list) -> list:
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
docs_with_deferred_revenue = ()
docs_with_deferred_expense = ()
docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item",
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
fields=["parent"],
as_list=1,
)
if sales_docs:
docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item",
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
fields=["parent"],
as_list=1,
)
if purchase_docs:
docs_with_deferred_expense = frappe.db.get_all(
"Purchase Invoice Item",
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
fields=["parent"],
as_list=1,
)
docs_with_deferred_expense = frappe.db.get_all(
"Purchase Invoice Item",
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
fields=["parent"],
as_list=1,
)
if docs_with_deferred_revenue or docs_with_deferred_expense:
frappe.throw(

View File

@@ -70,23 +70,9 @@ QI_OUTGOING_PURPOSES = (
)
SECONDARY_ITEM_PURPOSES = ("Manufacture", "Repack", "Disassemble")
def is_inspection_exempt_secondary_row(doc, row) -> bool:
"""Whether the row is a secondary item on a document that produces secondary items."""
if not (row.get("type") or row.get("is_legacy_scrap_item")):
return False
if doc.doctype == "Stock Entry":
return doc.purpose in SECONDARY_ITEM_PURPOSES
return True
def stock_entry_row_requires_inspection(purpose, row):
"""Check if this Stock Entry row need a Quality Inspection."""
if purpose in SECONDARY_ITEM_PURPOSES and (row.get("type") or row.get("is_legacy_scrap_item")):
if row.get("type") or row.get("is_legacy_scrap_item"):
return False
if purpose == "Manufacture":
return bool(row.is_finished_item)
@@ -1618,7 +1604,7 @@ class StockController(AccountsController):
elif self.doctype == "Stock Entry":
qi_required = stock_entry_row_requires_inspection(self.purpose, row)
if is_inspection_exempt_secondary_row(self, row):
if row.get("type") or row.get("is_legacy_scrap_item"):
continue
if qi_required: # validate row only if inspection is required on item level

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,9 @@
<div class="row" style="border-bottom:1px solid var(--border-color); padding:4px 5px; margin-top: 3px;margin-bottom: 3px;">
<div class="col-sm-1">
{% if(row.image) { %}
<img style="width:50px;height:50px;" src="{{frappe.utils.escape_html(row.image)}}">
<img style="width:50px;height:50px;" src="{{row.image}}">
{% } else { %}
<div style="width:50px;height:50px;background-color:var(--control-bg);text-align:center;padding-top:15px">{{frappe.get_abbr(frappe.utils.escape_html(row.item_code), 2)}}</div>
<div style="width:50px;height:50px;background-color:var(--control-bg);text-align:center;padding-top:15px">{{frappe.get_abbr(row.item_code, 2)}}</div>
{% } %}
</div>
<div class="col-sm-3">
@@ -13,7 +13,7 @@
{% } else { %}
{{row.item_link}}
<p>
{{frappe.utils.escape_html(row.item_name)}}
{{row.item_name}}
</p>
{% } %}
@@ -52,10 +52,10 @@
</span>
</div>
<div class="col-sm-1">
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" data-item-code="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Add") }}</button>
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" data-item-code="{{ escape(row.item_code) }}">{{ __("Add") }}</button>
</div>
<div class="col-sm-1">
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-move" data-item-code="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Move") }}</button>
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-move" data-item-code="{{ escape(row.item_code) }}">{{ __("Move") }}</button>
</div>
</div>
{% }); %}

View File

@@ -513,7 +513,7 @@ def get_workstations(**kwargs):
d.color = color_map.get(d.status, "red")
d.workstation_link = get_url_to_form("Workstation", d.name)
if d.status != "Production":
d.status_image = frappe.utils.escape_html(d.off_status_image)
d.status_image = d.off_status_image
d.workstation_off = "workstation-off"
return data

View File

@@ -20,10 +20,8 @@ erpnext.stock.qi_outgoing_purposes = [
];
erpnext.stock.is_incoming_qi_purpose = (purpose) =>
purpose === "Manufacture" || erpnext.stock.qi_incoming_purposes.includes(purpose);
erpnext.stock.secondary_item_purposes = ["Manufacture", "Repack", "Disassemble"];
erpnext.stock.row_requires_quality_inspection = (purpose, row) => {
if (erpnext.stock.secondary_item_purposes.includes(purpose) && (row.type || row.is_legacy_scrap_item))
return false;
if (row.type || row.is_legacy_scrap_item) return false;
if (purpose === "Manufacture") return !!row.is_finished_item;
if (erpnext.stock.qi_incoming_purposes.includes(purpose)) return !!row.t_warehouse;
if (erpnext.stock.qi_outgoing_purposes.includes(purpose))

View File

@@ -168,7 +168,7 @@ class VisualPlantFloor {
.find(".workstation-image-container")
.append(
`<div class="workstation-image-cls workstation-abbr" style="margin:6px; height:82px">${frappe.get_abbr(
frappe.utils.escape_html(data.name),
data.name,
2
)}</div>`
);

View File

@@ -1,19 +1,17 @@
<div class="app-listing item-list image-view-container item-selector">
{% for (var i=0; i < data.length; i++) { var item = data[i]; %}
{% const item_name = frappe.utils.escape_html(item.name); %}
{% const item_title = frappe.utils.escape_html(item.item_name || item.name); %}
{% if (i % 4 === 0) { %}<div class="image-view-row">{% } %}
<div class="image-view-item" data-name="{{ item_name }}">
<div class="image-view-item" data-name="{{ item.name }}">
<div class="image-view-header doclist-row">
<div class="list-value">
<a class="grey list-id" data-name="{{ item_name }}"
title="{{ item_title }}">
{{ item_title }}</a>
<a class="grey list-id" data-name="{{item.name}}"
title="{{ item.item_name || item.name}}">
{{item.item_name || item.name}}</a>
</div>
</div>
<div class="image-view-body">
<a data-item-code="{{ item_name }}"
title="{{ item_title }}"
<a data-item-code="{{ item.name }}"
title="{{ item.item_name || item.name }}"
>
<div class="image-field"
style="
@@ -24,11 +22,11 @@
>
{% if (!item.image) { %}
<span class="placeholder-text">
{%= frappe.get_abbr(item_title) %}
{%= frappe.get_abbr(item.item_name || item.name) %}
</span>
{% } %}
{% if (item.image) { %}
<img src="{{ frappe.utils.escape_html(item.image) }}" alt="{{ item_title }}">
<img src="{{ item.image }}" alt="{{item.item_name || item.name}}">
{% } %}
</div>
</a>

View File

@@ -1,6 +1,5 @@
{% $.each(workstations, (idx, row) => { %}
{% const row_workstation_name = frappe.utils.escape_html(row.name); %}
<div class="workstation-wrapper" data-workstation="{{row_workstation_name}}">
<div class="workstation-wrapper" data-workstation="{{row.name}}">
<div class="workstation-status text-left" style="">
<span class="indicator-pill no-indicator-dot whitespace-nowrap {{row.color}}" style="margin: 8px 0px 0px 8px;">
<span class="workstation-status-title" style="font-size:10px">{{row.status}}</span>
@@ -11,12 +10,12 @@
{% if(row.status_image) { %}
<img class="workstation-image-cls" src="{{row.status_image}}">
{% } else { %}
<div class="workstation-image-cls workstation-abbr" style="margin:6px; height:82px">{{frappe.get_abbr(row_workstation_name, 2)}}</div>
<div class="workstation-image-cls workstation-abbr" style="margin:6px; height:82px">{{frappe.get_abbr(row.name, 2)}}</div>
{% } %}
</div>
<span class="ellipsis" title="{{row_workstation_name}}">
<span class="ellipsis" title="{{row.name}}">
<div style="font-size:11px; text-align:center;padding-bottom:8px">{{row.workstation_name}}</div>
</span>
</div>
</div>
{% }); %}
{% }); %}

View File

@@ -853,17 +853,7 @@ class Item(Document):
frappe.throw(_("Item {0} is not a template item.").format(frappe.bold(self.variant_of)))
if based_on == "Item Attribute":
previous_doc = self.get_doc_before_save()
saved_attributes = (
{(row.attribute, row.attribute_value) for row in previous_doc.attributes}
if previous_doc
else set()
)
for d in self.attributes:
if (d.attribute, d.attribute_value) in saved_attributes:
continue
if not frappe.db.exists(
"Item Variant Attribute", {"attribute": d.attribute, "parent": self.variant_of}
):

View File

@@ -410,24 +410,6 @@ class TestItem(ERPNextTestSuite):
self.assertRaises(InvalidItemAttributeValueError, attribute.save)
def test_disabled_attribute_blocks_only_attribute_changes(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
variant.save()
attribute = frappe.get_doc("Item Attribute", "Test Size")
attribute.disabled = 1
attribute.save()
variant.reload()
variant.description = "Edited after the attribute was disabled"
variant.save()
variant.reload()
variant.attributes[0].attribute_value = "Small"
self.assertRaises(frappe.ValidationError, variant.save)
def test_rename_attribute_value_updates_variants(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)

View File

@@ -83,15 +83,6 @@ from erpnext.controllers.subcontracting_inward_controller import SubcontractingI
form_grid_templates = {"items": "templates/form_grid/stock_entry_grid.html"}
def is_costed_out_of_finished_item(row) -> bool:
"""Whether the row takes its value out of the finished good instead of adding to it.
A secondary item that is not linked to a BOM has no cost allocation of its own, so it is
valued the way the legacy scrap item was: its cost is deducted from the finished good.
"""
return bool(row.is_legacy_scrap_item or (row.type and not row.bom_secondary_item))
def _qty_tolerance(precision: int) -> float:
"""One unit at the column's precision -- absorbs float rounding without letting a real
(whole-unit) quantity divergence slip through."""
@@ -1456,12 +1447,9 @@ 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()
secondary_items_cost_basis = self.get_secondary_items_cost_basis(outgoing_items_cost)
items = []
# Set basic rate for incoming items
finished_items_last = sorted(self.get("items"), key=lambda row: cint(row.is_finished_item))
for d in finished_items_last:
for d in self.get("items"):
if d.s_warehouse or d.set_basic_rate_manually:
continue
@@ -1471,7 +1459,7 @@ class StockEntry(StockController, SubcontractingInwardController):
d.basic_amount = 0.0
continue
has_derived_rate = 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
@@ -1481,25 +1469,26 @@ class StockEntry(StockController, SubcontractingInwardController):
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
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
has_derived_rate = any(item.s_warehouse for item in self.get("items"))
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
elif d.type and d.bom_secondary_item:
cost_allocation_per = flt(
frappe.get_value("BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per")
cost_allocation_per = 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
# Only recalculate when cost is actually allocated; otherwise preserve the
# user-entered rate (or fall through to get_valuation_rate below)
if cost_allocation_per and flt(d.transfer_qty):
d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty
# 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:
# 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
@@ -1559,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"):
@@ -1612,43 +1671,11 @@ class StockEntry(StockController, SubcontractingInwardController):
)
return flt(outgoing_items_cost / total_fg_qty)
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 _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
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 is_costed_out_of_finished_item(d)]
)
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:
@@ -1685,7 +1712,20 @@ class StockEntry(StockController, SubcontractingInwardController):
)
)
outgoing_items_cost = self._fetch_consumption_entry_cost()
SE = frappe.qb.DocType("Stock Entry")
SE_ITEM = frappe.qb.DocType("Stock Entry Detail")
outgoing_items_cost = (
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
# 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.
@@ -2061,9 +2101,7 @@ class StockEntry(StockController, SubcontractingInwardController):
for d in self.items:
if d.t_warehouse and not d.s_warehouse:
if d.type or d.is_legacy_scrap_item:
d.is_finished_item = 0
elif self.purpose == "Repack" or d.item_code == finished_item:
if self.purpose == "Repack" or d.item_code == finished_item:
d.is_finished_item = 1
else:
d.is_finished_item = 0

View File

@@ -7,7 +7,6 @@ from frappe.utils import add_days, cstr, flt, get_time, getdate, nowtime, today
from erpnext.accounts.doctype.account.test_account import get_inventory_account
from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.exceptions import QualityInspectionRequiredError
from erpnext.stock.doctype.item.test_item import (
create_item,
make_item,
@@ -2738,6 +2737,38 @@ 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
@@ -2769,7 +2800,7 @@ class TestStockEntry(ERPNextTestSuite):
"item_code": scrap_item,
"t_warehouse": warehouse,
"qty": 5,
"type": "Scrap",
"secondary_item_type": "Scrap",
"conversion_factor": 1,
},
)
@@ -2807,7 +2838,7 @@ class TestStockEntry(ERPNextTestSuite):
bom.append(
"secondary_items",
{
"type": "Scrap",
"secondary_item_type": "Scrap",
"item_code": scrap_item,
"item_name": scrap_item,
"qty": 5,
@@ -2833,7 +2864,7 @@ class TestStockEntry(ERPNextTestSuite):
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.type)
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)
@@ -2868,7 +2899,7 @@ class TestStockEntry(ERPNextTestSuite):
bom.append(
"secondary_items",
{
"type": "Scrap",
"secondary_item_type": "Scrap",
"item_code": scrap_item,
"item_name": scrap_item,
"qty": 5,
@@ -2888,7 +2919,7 @@ class TestStockEntry(ERPNextTestSuite):
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.type)
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)
@@ -2896,96 +2927,7 @@ class TestStockEntry(ERPNextTestSuite):
self.assertEqual(flt(fg_row.basic_amount), 1000.0)
self.assertEqual(flt(se.value_difference), 0.0)
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,
"type": secondary_item_type,
},
)
return se
self.assertRaises(QualityInspectionRequiredError, receipt("").submit)
self.assertRaises(QualityInspectionRequiredError, receipt("Scrap").submit)
@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",
{
"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.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)
>>>>>>> 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 (