feat: valuation type for BOM secondary items (backport #58431) (#58632)

This commit is contained in:
Mihir Kandoi
2026-09-01 11:45:52 +05:30
committed by GitHub
parent ff656b9ad2
commit 1b44df480f
30 changed files with 1439 additions and 273 deletions

View File

@@ -75,7 +75,7 @@ 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("secondary_item_type") or row.get("is_legacy_scrap_item")):
if not (row.get("secondary_item_type") or row.get("valuation_type")):
return False
if doc.doctype == "Stock Entry":
@@ -86,9 +86,7 @@ def is_inspection_exempt_secondary_row(doc, row) -> bool:
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("secondary_item_type") or row.get("is_legacy_scrap_item")
):
if purpose in SECONDARY_ITEM_PURPOSES and (row.get("secondary_item_type") or row.get("valuation_type")):
return False
if purpose == "Manufacture":
return bool(row.is_finished_item)

View File

@@ -161,7 +161,7 @@ class SubcontractingController(StockController):
).format(item.idx, get_link_to_form("Item", item.item_code))
)
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("valuation_type"):
if not is_sub_contracted_item:
frappe.throw(
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
@@ -1288,10 +1288,10 @@ class SubcontractingController(StockController):
total_amt = sum(
flt(item.amount)
for item in self.get("items")
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
if not item.get("secondary_item_type") and not item.get("valuation_type")
)
for item in self.items:
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("valuation_type"):
item.additional_cost_per_qty = (
(item.amount * self.total_additional_costs) / total_amt
) / item.qty
@@ -1299,15 +1299,15 @@ class SubcontractingController(StockController):
total_qty = sum(
flt(item.qty)
for item in self.get("items")
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
if not item.get("secondary_item_type") and not item.get("valuation_type")
)
additional_cost_per_qty = self.total_additional_costs / total_qty
for item in self.items:
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("valuation_type"):
item.additional_cost_per_qty = additional_cost_per_qty
else:
for item in self.items:
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("valuation_type"):
item.additional_cost_per_qty = 0
@frappe.whitelist()

View File

@@ -242,7 +242,7 @@ class SubcontractingInwardController:
for item in self.get("items")
if not item.is_finished_item
and not item.secondary_item_type
and not item.is_legacy_scrap_item
and not item.valuation_type
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
]
@@ -372,7 +372,7 @@ class SubcontractingInwardController:
if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]:
for item in self.items:
if (
item.is_finished_item or item.secondary_item_type or item.is_legacy_scrap_item
item.is_finished_item or item.secondary_item_type or item.valuation_type
) and item.valuation_rate == 0:
item.allow_zero_valuation_rate = 1
@@ -472,7 +472,7 @@ class SubcontractingInwardController:
self.validate_delivery_on_save()
else:
for item in self.items:
if not item.secondary_item_type and not item.is_legacy_scrap_item:
if not item.secondary_item_type and not item.valuation_type:
delivered_qty, returned_qty = frappe.get_value(
"Subcontracting Inward Order Item",
item.scio_detail,
@@ -543,7 +543,7 @@ class SubcontractingInwardController:
bold(
frappe.get_cached_value(
"Subcontracting Inward Order Item"
if not item.secondary_item_type and not item.is_legacy_scrap_item
if not item.secondary_item_type and not item.valuation_type
else "Subcontracting Inward Order Secondary Item",
item.scio_detail,
"stock_uom",
@@ -595,7 +595,7 @@ class SubcontractingInwardController:
)
for item in [item for item in self.items if not item.is_finished_item]:
if item.secondary_item_type or item.is_legacy_scrap_item:
if item.secondary_item_type or item.valuation_type:
scio_secondary_item = frappe.get_value(
"Subcontracting Inward Order Secondary Item",
{
@@ -655,7 +655,7 @@ class SubcontractingInwardController:
for item in self.items:
doctype = (
"Subcontracting Inward Order Item"
if not item.secondary_item_type and not item.is_legacy_scrap_item
if not item.secondary_item_type and not item.valuation_type
else "Subcontracting Inward Order Secondary Item"
)
qty_map[doctype][item.scio_detail] += (
@@ -791,7 +791,7 @@ class SubcontractingInwardController:
items = [
item
for item in self.items
if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item
if not item.is_finished_item and not item.secondary_item_type and not item.valuation_type
]
item_code_wh = frappe._dict(
{
@@ -894,7 +894,7 @@ class SubcontractingInwardController:
def update_inward_order_secondary_items(self):
if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture":
secondary_items_list = [
item for item in self.items if item.secondary_item_type or item.is_legacy_scrap_item
item for item in self.items if item.secondary_item_type or item.valuation_type
]
secondary_items = defaultdict(float)

View File

@@ -753,6 +753,7 @@ var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
conversion_factor: d.conversion_factor,
sourced_by_supplier: d.sourced_by_supplier,
do_not_explode: d.do_not_explode,
source_warehouse: d.source_warehouse || doc.default_source_warehouse,
fetch_rate: !secondary_items,
},
callback: function (r) {
@@ -763,6 +764,10 @@ var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
doc = locals[doc.doctype][doc.name];
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_total(doc);
if (secondary_items && d.valuation_type === "Valuation Rate") {
erpnext.bom.fetch_secondary_item_cost(doc, cdt, cdn);
}
},
freeze: true,
});
@@ -776,11 +781,10 @@ cur_frm.cscript.qty = function (doc) {
cur_frm.cscript.rate = function (doc, cdt, cdn) {
var d = locals[cdt][cdn];
const is_secondary_item = cdt == "BOM Secondary Item";
if (d.bom_no) {
frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item."));
get_bom_material_detail(doc, cdt, cdn, is_secondary_item);
get_bom_material_detail(doc, cdt, cdn, false);
} else {
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_total(doc);
@@ -943,6 +947,9 @@ frappe.ui.form.on("BOM Item", {
do_not_explode: function (frm, cdt, cdn) {
get_bom_material_detail(frm.doc, cdt, cdn, false);
},
source_warehouse: function (frm, cdt, cdn) {
get_bom_material_detail(frm.doc, cdt, cdn, false);
},
});
frappe.ui.form.on("BOM Item", "qty", function (frm, cdt, cdn) {
@@ -1015,11 +1022,48 @@ frappe.tour["BOM"] = [
];
frappe.ui.form.on("BOM Secondary Item", {
item_code(frm, cdt, cdn) {
const { item_code } = locals[cdt][cdn];
valuation_type(frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (row.valuation_type !== "% of FG Cost") {
frappe.model.set_value(cdt, cdn, "cost_allocation_per", 0);
}
if (row.valuation_type === "Valuation Rate") {
erpnext.bom.fetch_secondary_item_cost(frm.doc, cdt, cdn);
} else if (row.valuation_type !== "Manual") {
frappe.model.set_value(cdt, cdn, { cost: 0, base_cost: 0 });
}
},
});
erpnext.bom.fetch_secondary_item_cost = function (doc, cdt, cdn) {
const row = locals[cdt][cdn];
if (!row.item_code) return;
frappe.call({
doc: doc,
method: "get_bom_material_detail",
args: {
company: doc.company,
item_code: row.item_code,
uom: row.uom,
stock_uom: row.stock_uom,
conversion_factor: row.conversion_factor,
warehouse: doc.default_target_warehouse,
set_rate_based_on_warehouse: 1,
force_valuation_rate: 1,
fetch_rate: 1,
bom_no: "",
},
callback(r) {
const cost = flt(r.message.rate) * flt(row.stock_qty);
frappe.model.set_value(cdt, cdn, {
cost: cost,
base_cost: cost * flt(doc.conversion_rate || 1),
});
},
});
};
function trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code) {
frappe.prompt(
{

View File

@@ -392,7 +392,7 @@
{
"fetch_from": "item.description",
"fieldname": "description",
"fieldtype": "Small Text",
"fieldtype": "Text Editor",
"label": "Item Description",
"read_only": 1
},
@@ -761,7 +761,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2026-04-17 15:22:33.598938",
"modified": "2026-08-23 15:20:11.032436",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM",

View File

@@ -302,7 +302,7 @@ class BOM(WebsiteGenerator):
self.set_default_uom()
self.validate_semi_finished_goods()
self.validate_secondary_items()
self.set_fg_cost_allocation()
self.validate_secondary_items_cost()
self.validate_total_cost_allocation()
if self.docstatus == 1:
@@ -361,8 +361,20 @@ class BOM(WebsiteGenerator):
)
def validate_secondary_items(self):
seen_items = set()
for item in self.secondary_items:
if not item.is_legacy and item.item_code == self.item:
# every consumer merges secondary rows by item and type, so duplicates cannot
# keep their own quantities, percentages or valuation mode
key = (item.item_code, item.secondary_item_type or "")
if key in seen_items:
frappe.throw(
_(
"Row #{0}: Item {1} is already added with the same Type in the Secondary Items table."
).format(item.idx, get_link_to_form("Item", item.item_code))
)
seen_items.add(key)
if item.valuation_type != "Valuation Rate" and item.item_code == self.item:
frappe.throw(
_(
"Row #{0}: Finished Good Item {1} cannot be added in the Secondary Items table."
@@ -453,13 +465,25 @@ class BOM(WebsiteGenerator):
def set_fg_cost_allocation(self):
total_secondary_items_per = 0
own_cost = 0
for item in self.secondary_items:
if item.valuation_type in ("Valuation Rate", "Manual"):
item.cost_allocation_per = 0
own_cost += flt(item.cost)
total_secondary_items_per += item.cost_allocation_per
if self.cost_allocation_per == 100 and total_secondary_items_per:
self.cost_allocation_per -= total_secondary_items_per
self.cost_allocation = self.raw_material_cost * (self.cost_allocation_per / 100)
self.cost_allocation = (self.raw_material_cost - own_cost) * (self.cost_allocation_per / 100)
def validate_secondary_items_cost(self):
if flt(self.secondary_items_cost) > flt(self.raw_material_cost):
frappe.throw(
_("The cost of the secondary items cannot exceed the raw material cost of {0}.").format(
frappe.bold(flt(self.raw_material_cost))
)
)
def validate_total_cost_allocation(self):
total_cost_allocation_per = self.cost_allocation_per
@@ -530,6 +554,7 @@ class BOM(WebsiteGenerator):
"conversion_factor": item.conversion_factor,
"sourced_by_supplier": item.sourced_by_supplier,
"do_not_explode": item.do_not_explode,
"source_warehouse": item.source_warehouse or self.default_source_warehouse,
"fetch_rate": True,
}
)
@@ -615,7 +640,12 @@ class BOM(WebsiteGenerator):
if not self.rm_cost_as_per:
self.rm_cost_as_per = "Valuation Rate"
if arg:
if arg and arg.get("force_valuation_rate"):
# Valuation Rate secondary items ignore the BOM's rm_cost_as_per method:
# bin-average valuation like the raw materials, scoped to the default
# target warehouse when set.
rate = get_valuation_rate(arg)
elif arg:
# Customer Provided parts and Supplier sourced parts will have zero rate
if not frappe.db.get_value("Item", arg["item_code"], "is_customer_provided_item") and not arg.get(
"sourced_by_supplier"
@@ -950,6 +980,7 @@ class BOM(WebsiteGenerator):
self.calculate_op_cost(update_hour_rate)
self.calculate_rm_cost(save=save_updates)
self.calculate_secondary_items_costs(save=save_updates)
self.set_fg_cost_allocation()
if save_updates:
# not via doc event, table is not regenerated and needs updation
self.calculate_exploded_cost()
@@ -1036,6 +1067,7 @@ class BOM(WebsiteGenerator):
"conversion_factor": d.conversion_factor,
"sourced_by_supplier": d.sourced_by_supplier,
"is_phantom_item": d.is_phantom_item,
"source_warehouse": d.source_warehouse or self.default_source_warehouse,
},
notify=False,
)
@@ -1058,24 +1090,54 @@ class BOM(WebsiteGenerator):
self.base_raw_material_cost = base_total_rm_cost
def calculate_secondary_items_costs(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals"""
"""Valuation Rate and Manual rows carry their own cost, deducted from the raw
material cost; the % of FG Cost rows split the remainder by their percentage."""
total_sm_cost = 0
base_total_sm_cost = 0
precision = self.precision("raw_material_cost")
allocation_basis = flt(self.raw_material_cost) - self._set_own_cost_secondary_items(precision, save)
for d in self.get("secondary_items"):
if not d.is_legacy:
d.cost = flt(self.raw_material_cost * (d.cost_allocation_per / 100), precision)
if d.valuation_type not in ("Valuation Rate", "Manual"):
d.cost = flt(allocation_basis * (d.cost_allocation_per / 100), precision)
d.base_cost = flt(d.cost * self.conversion_rate, precision)
total_sm_cost += d.cost
base_total_sm_cost += d.base_cost
if save:
d.db_update()
total_sm_cost += d.cost
base_total_sm_cost += d.base_cost
self.secondary_items_cost = total_sm_cost
self.base_secondary_items_cost = base_total_sm_cost
def _set_own_cost_secondary_items(self, precision, save) -> float:
"""Cost of the rows valued on their own: fetched for Valuation Rate, kept for Manual."""
total = 0.0
for d in self.get("secondary_items"):
if d.valuation_type == "Valuation Rate":
rate = self.get_rm_rate(self._secondary_item_rate_args(d))
d.cost = flt(flt(rate) * flt(d.stock_qty), precision)
elif d.valuation_type == "Manual":
d.cost = flt(d.cost, precision)
else:
continue
d.base_cost = flt(d.cost * self.conversion_rate, precision)
total += d.cost
if save:
d.db_update()
return total
def _secondary_item_rate_args(self, d):
return {
"item_code": d.item_code,
"company": self.company,
"warehouse": self.default_target_warehouse,
"set_rate_based_on_warehouse": 1,
"force_valuation_rate": 1,
}
def calculate_exploded_cost(self):
"Set exploded row cost from it's parent BOM."
rm_rate_map = self.get_rm_rate_map()
@@ -1298,7 +1360,8 @@ class BOM(WebsiteGenerator):
def has_scrap_items(self):
return any(
d.get("secondary_item_type") == "Scrap" or d.get("is_legacy") for d in self.get("secondary_items")
d.get("secondary_item_type") == "Scrap" or d.get("valuation_type") == "Valuation Rate"
for d in self.get("secondary_items")
)
@@ -1365,8 +1428,12 @@ def get_valuation_rate(data):
.where((bin_table.item_code == item_code) & (wh_table.company == company))
)
warehouse = data.get("source_warehouse")
if data.get("set_rate_based_on_warehouse") and data.get("warehouse"):
item_valuation = item_valuation.where(bin_table.warehouse == data.get("warehouse"))
warehouse = data.get("warehouse")
if warehouse:
item_valuation = item_valuation.where(bin_table.warehouse == warehouse)
item_valuation = item_valuation.run(as_dict=True)[0]
@@ -1415,7 +1482,7 @@ def get_bom_items_as_dict(
if fetch_secondary_items:
fetch_exploded = 0
group_by_cond = "group by item_code"
group_by_cond = "group by item_code, secondary_item_type"
# Did not use qty_consumed_per_unit in the query, as it leads to rounding loss
query = """select
@@ -1464,10 +1531,10 @@ def get_bom_items_as_dict(
query, {"parent": bom, "qty": qty, "bom": bom, "company": company}, as_dict=True
)
elif fetch_secondary_items:
query = query.format(
query = query.format( # nosemgrep
table="BOM Secondary Item",
where_conditions=")",
select_columns=", item.description, bom_item.cost_allocation_per, bom_item.process_loss_per, bom_item.secondary_item_type, bom_item.name, bom_item.is_legacy",
select_columns=", item.description, bom_item.cost_allocation_per, bom_item.process_loss_per, bom_item.secondary_item_type, bom_item.name, bom_item.valuation_type, bom_item.cost / nullif(bom_item.stock_qty, 0) as manual_rate",
is_stock_item=is_stock_item,
qty_field="stock_qty",
group_by_cond=group_by_cond,
@@ -1490,6 +1557,9 @@ def get_bom_items_as_dict(
for item in items:
key = item.item_code
if fetch_secondary_items:
key = (item.item_code, item.secondary_item_type or "")
if item.operation_row_id:
key = (item.item_code, item.operation_row_id)

View File

@@ -435,7 +435,7 @@ class TestBOM(ERPNextTestSuite):
fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items()
bom_doc = create_bom_with_process_loss_item(
fg_item_non_whole, bom_item, scrap_qty=2, scrap_rate=0, process_loss_percentage=110
fg_item_non_whole, bom_item, scrap_qty=2, process_loss_percentage=110
)
# PL can't be > 100
self.assertRaises(frappe.ValidationError, bom_doc.submit)
@@ -462,12 +462,224 @@ class TestBOM(ERPNextTestSuite):
"secondary_item_type": "Additional Finished Good",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
},
)
# FG item of the BOM cannot also be a secondary item
self.assertRaises(frappe.ValidationError, bom_doc.save)
@timeout
def test_duplicate_secondary_item_not_allowed(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 1, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": scrap_item,
"secondary_item_type": "Scrap",
"qty": 1,
"valuation_type": "Valuation Rate",
},
)
bom_doc.append(
"secondary_items",
{
"item_code": scrap_item,
"secondary_item_type": "Scrap",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
},
)
self.assertRaises(frappe.ValidationError, bom_doc.save)
# the same item with a different type is a distinct secondary output
bom_doc.secondary_items[1].secondary_item_type = "By-Product"
bom_doc.save()
@timeout
def test_secondary_item_manual_cost(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
by_product = make_item(properties={"is_stock_item": 1}).name
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 10, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": by_product,
"secondary_item_type": "By-Product",
"qty": 1,
"valuation_type": "Manual",
"cost": 150,
},
)
bom_doc.save()
row = bom_doc.secondary_items[0]
self.assertEqual(row.cost, 150)
self.assertEqual(row.cost_allocation_per, 0)
self.assertEqual(bom_doc.secondary_items_cost, 150)
self.assertEqual(bom_doc.total_cost, 850)
self.assertEqual(bom_doc.cost_allocation, 850)
# a manual cost above the raw material cost would turn the finished good negative
bom_doc.secondary_items[0].cost = 1100
self.assertRaises(frappe.ValidationError, bom_doc.save)
@timeout
def test_secondary_item_valuation_rate_method(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name
by_product = make_item(properties={"is_stock_item": 1}).name
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 10, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": scrap_item,
"secondary_item_type": "Scrap",
"qty": 2,
"valuation_type": "Valuation Rate",
},
)
bom_doc.append(
"secondary_items",
{
"item_code": by_product,
"secondary_item_type": "By-Product",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
},
)
bom_doc.save()
scrap_row = bom_doc.secondary_items[0]
self.assertEqual(scrap_row.cost, 100)
self.assertEqual(scrap_row.cost_allocation_per, 0)
# the by-product's percentage applies to the cost net of the valuation rate rows
self.assertEqual(bom_doc.raw_material_cost, 1000)
self.assertEqual(bom_doc.secondary_items[1].cost, 90)
self.assertEqual(bom_doc.cost_allocation_per, 90)
self.assertEqual(bom_doc.cost_allocation, 810)
self.assertEqual(bom_doc.secondary_items_cost, 190)
self.assertEqual(bom_doc.total_cost, 810)
@timeout
def test_rm_rate_scoped_to_source_warehouse(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item_code=rm_item, target="_Test Warehouse - _TC", qty=10, basic_rate=100)
make_stock_entry(item_code=rm_item, target="_Test Warehouse 1 - _TC", qty=10, basic_rate=50)
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 1, "source_warehouse": "_Test Warehouse - _TC"})
bom_doc.save()
self.assertEqual(bom_doc.items[0].rate, 100)
bom_doc.items[0].source_warehouse = None
bom_doc.save()
self.assertEqual(bom_doc.items[0].rate, 75)
bom_doc.default_source_warehouse = "_Test Warehouse 1 - _TC"
bom_doc.save()
self.assertEqual(bom_doc.items[0].rate, 50)
@timeout
def test_secondary_item_rate_scoped_to_target_warehouse(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
scrap_item = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item_code=scrap_item, target="_Test Warehouse - _TC", qty=10, basic_rate=40)
make_stock_entry(item_code=scrap_item, target="_Test Warehouse 1 - _TC", qty=10, basic_rate=20)
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.default_target_warehouse = "_Test Warehouse - _TC"
bom_doc.append("items", {"item_code": rm_item, "qty": 10, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": scrap_item,
"secondary_item_type": "Scrap",
"qty": 1,
"valuation_type": "Valuation Rate",
},
)
bom_doc.save()
self.assertEqual(bom_doc.secondary_items[0].cost, 40)
bom_doc.default_target_warehouse = None
bom_doc.save()
self.assertEqual(bom_doc.secondary_items[0].cost, 30)
@timeout
def test_secondary_item_valuation_rate_refreshed_on_update_cost(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 10, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": scrap_item,
"secondary_item_type": "Scrap",
"qty": 2,
"valuation_type": "Valuation Rate",
},
)
bom_doc.save()
bom_doc.submit()
frappe.db.set_value("Item", scrap_item, "valuation_rate", 80)
bom_doc.update_cost()
bom_doc.reload()
self.assertEqual(bom_doc.secondary_items[0].cost, 160)
self.assertEqual(bom_doc.total_cost, 840)
self.assertEqual(bom_doc.cost_allocation, 840)
@timeout
def test_bom_item_query(self):
query = partial(
@@ -1126,7 +1338,7 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non
def create_bom_with_process_loss_item(
fg_item, bom_item, scrap_qty=0, scrap_rate=0, fg_qty=2, process_loss_percentage=0, company=None
fg_item, bom_item, scrap_qty=0, fg_qty=2, process_loss_percentage=0, company=None
):
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item.item_code
@@ -1148,11 +1360,11 @@ def create_bom_with_process_loss_item(
"secondary_items",
{
"item_code": fg_item.item_code,
"secondary_item_type": "Scrap",
"qty": scrap_qty,
"stock_qty": scrap_qty,
"uom": fg_item.stock_uom,
"stock_uom": fg_item.stock_uom,
"rate": scrap_rate,
},
)

View File

@@ -38,15 +38,13 @@
{
"secondary_items":[
{
"amount": 2000.0,
"doctype": "BOM Secondary Item",
"item_code": "_Test Item Home Desktop 100",
"parentfield": "secondary_items",
"stock_qty": 1.0,
"rate": 2000.0,
"stock_uom": "_Test UOM",
"secondary_item_type": "Scrap",
"is_legacy": 1
"valuation_type": "Valuation Rate"
}
],
"items": [

View File

@@ -6,13 +6,10 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"secondary_item_type",
"rate",
"column_break_gres",
"is_legacy",
"section_break_sbnk",
"item_code",
"item_name",
"secondary_item_type",
"uom",
"column_break_atlf",
"qty",
@@ -25,6 +22,7 @@
"column_break_wsra",
"image_nygv",
"section_break_ielf",
"valuation_type",
"cost_allocation_per",
"process_loss_per",
"column_break_gtbl",
@@ -34,13 +32,12 @@
],
"fields": [
{
"depends_on": "eval:!doc.is_legacy",
"fieldname": "secondary_item_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"mandatory_depends_on": "eval:!doc.is_legacy",
"options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good"
"options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good",
"reqd": 1
},
{
"fieldname": "item_code",
@@ -63,10 +60,9 @@
"fieldname": "cost",
"fieldtype": "Currency",
"label": "Cost",
"no_copy": 1,
"non_negative": 1,
"options": "currency",
"read_only": 1,
"read_only_depends_on": "eval:doc.valuation_type != 'Manual'",
"reqd": 1
},
{
@@ -103,7 +99,6 @@
"reqd": 1
},
{
"depends_on": "eval:!doc.is_legacy",
"fieldname": "section_break_ielf",
"fieldtype": "Section Break"
},
@@ -143,6 +138,7 @@
},
{
"default": "0",
"depends_on": "eval:doc.valuation_type == '% of FG Cost'",
"fieldname": "cost_allocation_per",
"fieldtype": "Percent",
"label": "Cost Allocation %",
@@ -175,33 +171,20 @@
"fieldtype": "Currency",
"hidden": 1,
"label": "Base Cost (Company Currency)",
"no_copy": 1,
"non_negative": 1,
"options": "Company:company:default_currency",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "column_break_gres",
"fieldtype": "Column Break"
},
{
"default": "0",
"depends_on": "is_legacy",
"fieldname": "is_legacy",
"fieldtype": "Check",
"label": "Is Legacy",
"no_copy": 1,
"read_only": 1
},
{
"depends_on": "eval:doc.is_legacy",
"fieldname": "rate",
"fieldtype": "Currency",
"label": "Rate",
"no_copy": 1,
"non_negative": 1,
"read_only": 1
"default": "Valuation Rate",
"description": "Valuation Rate and Manual value this item on its own and deduct that cost from the raw material cost, like the pre-v16 scrap items. % of FG Cost allocates a percentage of the remaining raw material cost.",
"fieldname": "valuation_type",
"fieldtype": "Select",
"label": "Valuation Type",
"options": "Valuation Rate\n% of FG Cost\nManual",
"reqd": 1,
"show_description_on_click": 1
},
{
"default": "0",

View File

@@ -20,7 +20,6 @@ class BOMSecondaryItem(Document):
cost_allocation_per: DF.Percent
description: DF.TextEditor | None
image: DF.AttachImage | None
is_legacy: DF.Check
item_code: DF.Link
item_name: DF.Data | None
parent: DF.Data
@@ -29,11 +28,11 @@ class BOMSecondaryItem(Document):
process_loss_per: DF.Percent
process_loss_qty: DF.Float
qty: DF.Float
rate: DF.Currency
secondary_item_type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
stock_qty: DF.Float
stock_uom: DF.Link | None
secondary_item_type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
uom: DF.Link
valuation_type: DF.Literal["Valuation Rate", "% of FG Cost", "Manual"]
# end: auto-generated types
pass

View File

@@ -293,10 +293,10 @@ class JobCard(Document):
fetch_exploded=0,
fetch_secondary_items=1,
)
for item_code, values in items_dict.items():
for values in items_dict.values():
values = frappe._dict(values)
secondary_item = {
"item_code": item_code,
"item_code": values.item_code,
"stock_qty": values.qty,
"item_name": values.item_name,
"stock_uom": values.stock_uom,
@@ -304,11 +304,10 @@ class JobCard(Document):
"bom_secondary_item": values.name,
}
if not values.is_legacy:
secondary_item["stock_qty"] -= flt(
secondary_item["stock_qty"] * (values.process_loss_per / 100),
self.precision("for_quantity"),
)
secondary_item["stock_qty"] -= flt(
secondary_item["stock_qty"] * (flt(values.process_loss_per) / 100),
self.precision("for_quantity"),
)
self.append("secondary_items", secondary_item)
@@ -1758,7 +1757,7 @@ class JobCard(Document):
ste.stock_entry.pro_doc = frappe.get_doc("Work Order", self.work_order)
ste.stock_entry.set_secondary_items_from_job_card()
for row in ste.stock_entry.items:
if (row.secondary_item_type or row.is_legacy_scrap_item) and not row.t_warehouse:
if (row.secondary_item_type or row.valuation_type) and not row.t_warehouse:
row.t_warehouse = self.target_warehouse
if auto_submit:

View File

@@ -1341,6 +1341,7 @@ class TestJobCard(ERPNextTestSuite):
"qty": 1,
"process_loss_per": 10,
"cost_allocation_per": 5,
"valuation_type": "% of FG Cost",
"secondary_item_type": "Scrap",
},
)
@@ -2648,6 +2649,98 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(s.items[3].item_code, "_Test Item")
self.assertEqual(s.items[3].transfer_qty, 2)
frappe.db.set_value(
"Stock Entry Detail",
s.items[3].name,
{"secondary_item_type": None, "valuation_type": "Valuation Rate"},
)
stock_entry = frappe.get_doc({"doctype": "Stock Entry", "work_order": self.work_order.name})
used_secondary_items = stock_entry.get_used_secondary_items()
self.assertEqual(used_secondary_items[("_Test Item", "Scrap")], 2)
def test_secondary_items_from_multiple_boms_stay_separate(self):
"""Rows linked to different BOM rows keep their own quantity and valuation mode."""
from erpnext.stock.doctype.item.test_item import make_item
secondary_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name
bom_links = []
for cost_allocation_per in (0, 10):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 1, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": secondary_item,
"secondary_item_type": "Scrap",
"qty": 1,
"cost_allocation_per": cost_allocation_per,
"valuation_type": "% of FG Cost" if cost_allocation_per else "Valuation Rate",
},
)
bom_doc.save()
bom_doc.submit()
bom_links.append(bom_doc.secondary_items[0].name)
for row in frappe.get_doc("BOM", self.work_order.bom_no).items:
make_stock_entry(
item_code=row.item_code,
target="_Test Warehouse - _TC",
qty=10,
basic_rate=100,
)
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
job_card.append(
"secondary_items",
{
"item_code": secondary_item,
"stock_qty": 2,
"secondary_item_type": "Scrap",
"bom_secondary_item": bom_links[0],
},
)
job_card.append(
"secondary_items",
{
"item_code": secondary_item,
"stock_qty": 3,
"secondary_item_type": "Scrap",
"bom_secondary_item": bom_links[1],
},
)
job_card.append(
"time_logs",
{
"from_time": "2009-01-01 12:06:25",
"to_time": "2009-01-01 12:37:25",
"completed_qty": job_card.for_quantity,
},
)
job_card.save()
job_card.submit()
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_for_wo,
)
s = frappe.get_doc(make_stock_entry_for_wo(self.work_order.name, "Manufacture"))
rows = {d.bom_secondary_item: d for d in s.items if d.item_code == secondary_item}
self.assertEqual(len(rows), 2)
self.assertEqual(rows[bom_links[0]].qty, 2)
self.assertEqual(rows[bom_links[0]].valuation_type, "Valuation Rate")
self.assertEqual(rows[bom_links[1]].qty, 3)
self.assertEqual(rows[bom_links[1]].valuation_type, "% of FG Cost")
@ERPNextTestSuite.change_settings(
"Manufacturing Settings", {"overproduction_percentage_for_work_order": 100}
)

View File

@@ -3233,6 +3233,7 @@ def make_bom(**args):
"stock_uom": item_doc.stock_uom,
"qty": args.scrap_qty or 1,
"cost_allocation_per": args.scrap_cost_allocation_per or 10,
"valuation_type": "% of FG Cost",
"process_loss_per": args.scrap_process_loss_per or 10,
},
)

View File

@@ -1115,7 +1115,7 @@ class TestWorkOrder(ERPNextTestSuite):
stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
for row in stock_entry.items:
if row.secondary_item_type or row.is_legacy_scrap_item:
if row.secondary_item_type or row.valuation_type:
self.assertEqual(row.qty, 1)
# Partial Job Card 1 with qty 10
@@ -1127,7 +1127,7 @@ class TestWorkOrder(ERPNextTestSuite):
stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
for row in stock_entry.items:
if row.secondary_item_type or row.is_legacy_scrap_item:
if row.secondary_item_type or row.valuation_type:
self.assertEqual(row.qty, 2)
# Partial Job Card 2 with qty 10
@@ -2501,7 +2501,7 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertTrue(se_doc.additional_costs)
secondary_items = []
for item in se_doc.items:
if item.secondary_item_type or item.is_legacy_scrap_item:
if item.secondary_item_type or item.valuation_type:
secondary_items.append(item.item_code)
self.assertEqual(
@@ -4923,6 +4923,7 @@ class TestWorkOrder(ERPNextTestSuite):
"item_name": scrap_item,
"qty": 3,
"cost_allocation_per": 25,
"valuation_type": "% of FG Cost",
"process_loss_per": 0,
},
)
@@ -4971,6 +4972,7 @@ class TestWorkOrder(ERPNextTestSuite):
"item_name": scrap_item,
"qty": 3,
"cost_allocation_per": 25,
"valuation_type": "% of FG Cost",
"process_loss_per": 0,
},
)
@@ -5160,7 +5162,15 @@ def prepare_boms_for_sub_assembly_test():
do_not_submit=True,
)
bom.append("secondary_items", {"item_code": "Test Final Scrap Item 1", "qty": 1, "is_legacy": 1})
bom.append(
"secondary_items",
{
"item_code": "Test Final Scrap Item 1",
"secondary_item_type": "Scrap",
"qty": 1,
"valuation_type": "Valuation Rate",
},
)
bom.submit()
@@ -5173,7 +5183,15 @@ def prepare_boms_for_sub_assembly_test():
do_not_submit=True,
)
bom.append("secondary_items", {"item_code": "Test Final Scrap Item 2", "qty": 1, "is_legacy": 1})
bom.append(
"secondary_items",
{
"item_code": "Test Final Scrap Item 2",
"secondary_item_type": "Scrap",
"qty": 1,
"valuation_type": "Valuation Rate",
},
)
bom.submit()

View File

@@ -10,7 +10,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Case
from frappe.query_builder.functions import IfNull, Sum
from frappe.query_builder.functions import Coalesce, IfNull, Sum
from frappe.utils import (
cint,
date_diff,
@@ -187,14 +187,11 @@ class WorkOrder(Document):
.where(
(parent.work_order == self.name)
& (parent.docstatus == 1)
& ((child.secondary_item_type != "") | (child.is_legacy_scrap_item == 1))
& ((child.secondary_item_type != "") | (Coalesce(child.valuation_type, "") != ""))
)
.select(
child.item_code,
Case()
.when(child.is_legacy_scrap_item == 1, "Scrap (Legacy)")
.else_(child.secondary_item_type)
.as_("secondary_item_type"),
Coalesce(child.secondary_item_type, "Scrap (Legacy)").as_("secondary_item_type"),
child.qty,
child.uom,
child.amount,

View File

@@ -504,3 +504,4 @@ erpnext.patches.v16_0.repair_work_order_material_transfer
erpnext.patches.v16_0.remove_frappe_crm_custom_fields
erpnext.patches.v16_0.rename_secondary_item_type_field
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
erpnext.patches.v16_0.set_secondary_item_valuation_type

View File

@@ -22,8 +22,10 @@ def copy_doctypes():
def insert_into_bom():
fields = ["item_code", "item_name", "stock_uom", "stock_qty", "rate"]
data = frappe.get_all("BOM Scrap Item", {"docstatus": ("<", 2)}, ["parent", *fields])
fields = ["item_code", "item_name", "stock_uom", "stock_qty"]
data = frappe.get_all(
"BOM Scrap Item", {"docstatus": ("<", 2)}, ["parent", *fields, "amount", "base_amount"]
)
grouped_data = defaultdict(list)
for item in data:
grouped_data[item.parent].append(item)
@@ -40,8 +42,10 @@ def insert_into_bom():
"uom": item.stock_uom,
"conversion_factor": 1,
"qty": item.stock_qty,
"is_legacy": 1,
"valuation_type": "Valuation Rate",
"secondary_item_type": "Scrap",
"cost": item.amount,
"base_cost": item.base_amount,
}
)
secondary_item.insert()
@@ -100,12 +104,21 @@ def bulk_insert(parent_doctype, old_doctype, new_doctype, old_fields, new_fields
def rename_fields():
rename_field("BOM", "scrap_material_cost", "secondary_items_cost")
rename_field("BOM", "base_scrap_material_cost", "base_secondary_items_cost")
rename_field("Stock Entry Detail", "is_scrap_item", "is_legacy_scrap_item")
set_valuation_type("Stock Entry Detail", "is_scrap_item")
rename_field(
"Manufacturing Settings",
"set_op_cost_and_scrap_from_sub_assemblies",
"set_op_cost_and_secondary_items_from_sub_assemblies",
)
rename_field("Selling Settings", "deliver_scrap_items", "deliver_secondary_items")
rename_field("Subcontracting Receipt Item", "is_scrap_item", "is_legacy_scrap_item")
set_valuation_type("Subcontracting Receipt Item", "is_scrap_item")
rename_field("Subcontracting Receipt Item", "scrap_cost_per_qty", "secondary_items_cost_per_qty")
def set_valuation_type(doctype, legacy_field):
"""The legacy scrap flag becomes the Valuation Rate method."""
if not frappe.db.has_column(doctype, legacy_field):
return
table = frappe.qb.DocType(doctype)
frappe.qb.update(table).set(table.valuation_type, "Valuation Rate").where(table[legacy_field] == 1).run()

View File

@@ -0,0 +1,79 @@
import frappe
from frappe.utils import flt
def execute():
"""Set valuation_type on sites that migrated before the field existed.
Fresh migrations get it from co_by_product_patch; the legacy columns never
existed there, so every step below is a no-op.
"""
for doctype, legacy_fields in (
("BOM Secondary Item", ["is_legacy", "use_valuation_rate"]),
("Stock Entry Detail", ["is_legacy_scrap_item", "use_valuation_rate"]),
("Subcontracting Receipt Item", ["is_legacy_scrap_item", "use_valuation_rate"]),
):
set_valuation_rate_method(doctype, legacy_fields)
set_percentage_method()
backfill_cost_from_rate()
def set_valuation_rate_method(doctype, legacy_fields):
table = frappe.qb.DocType(doctype)
for field in legacy_fields:
if not frappe.db.has_column(doctype, field):
continue
frappe.qb.update(table).set(table.valuation_type, "Valuation Rate").where(table[field] == 1).run()
def set_percentage_method():
"""Rows created by the percentage system before the method field existed.
Only BOM rows need this: the field is mandatory there, and the costing treats
the percentage method as the default for everything else."""
rows = frappe.get_all("BOM Secondary Item", filters={"valuation_type": ("is", "not set")}, pluck="name")
if not rows:
return
frappe.db.set_value(
"BOM Secondary Item",
{"name": ("in", rows)},
"valuation_type",
"% of FG Cost",
update_modified=False,
)
def backfill_cost_from_rate():
"""Earlier v16 builds stored the migrated scrap rate on the removed rate field."""
if not frappe.db.has_column("BOM Secondary Item", "rate"):
return
table = frappe.qb.DocType("BOM Secondary Item")
rows = (
frappe.qb.from_(table)
.select(table.name, table.parent, table.rate, table.stock_qty)
.where((table.valuation_type == "Valuation Rate") & (table.cost == 0) & (table.rate > 0))
).run(as_dict=True)
if not rows:
return
conversion_rates = dict(
frappe.get_all(
"BOM",
filters={"name": ("in", {row.parent for row in rows})},
fields=["name", "conversion_rate"],
as_list=True,
)
)
for row in rows:
cost = flt(row.rate) * flt(row.stock_qty)
frappe.db.set_value(
"BOM Secondary Item",
row.name,
{"cost": cost, "base_cost": cost * flt(conversion_rates.get(row.parent) or 1)},
update_modified=False,
)

View File

@@ -24,7 +24,7 @@ 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.secondary_item_type || row.is_legacy_scrap_item)
(row.secondary_item_type || row.valuation_type)
)
return false;
if (purpose === "Manufacture") return !!row.is_finished_item;

View File

@@ -3,6 +3,7 @@
from math import isfinite
from typing import Any
import frappe
from frappe import _
@@ -416,7 +417,7 @@ class QualityInspection(Document):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def item_query(doctype, txt, searchfield, start, page_len, filters):
def item_query(doctype: Any, txt: str | None, searchfield: Any, start: int, page_len: int, filters: dict):
reference_doctype = filters.get("reference_doctype")
if not reference_doctype:
@@ -446,7 +447,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters):
"and",
["items.secondary_item_type", "is", "not set"],
"and",
["items.is_legacy_scrap_item", "=", 0],
["items.valuation_type", "is", "not set"],
]
)
if purpose == "Manufacture":

View File

@@ -308,6 +308,13 @@ frappe.ui.form.on("Stock Entry", {
refresh: function (frm) {
frm.trigger("get_items_from_transit_entry");
frm.trigger("toggle_warehouse_fields");
// only BOM-less rows are editable, and they cannot allocate a BOM percentage;
// read-only rows from a BOM still display their stored % of FG Cost
frm.fields_dict.items.grid.update_docfield_property("valuation_type", "options", [
"Valuation Rate",
"Manual",
]);
erpnext.toggle_serial_batch_fields(frm);
if (!frm.doc.docstatus && !frm.doc.subcontracting_inward_order) {
@@ -1020,6 +1027,29 @@ frappe.ui.form.on("Stock Entry Detail", {
);
},
secondary_item_type(frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (row.bom_secondary_item) return;
if (!row.secondary_item_type) {
if (row.valuation_type) {
frappe.model.set_value(cdt, cdn, { valuation_type: "", set_basic_rate_manually: 0 });
}
return;
}
if (!row.valuation_type) {
frappe.model.set_value(cdt, cdn, "valuation_type", "Valuation Rate");
}
},
valuation_type(frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (!row.secondary_item_type || row.bom_secondary_item) return;
frappe.model.set_value(cdt, cdn, "set_basic_rate_manually", row.valuation_type === "Manual" ? 1 : 0);
},
conversion_factor(frm, cdt, cdn) {
frm.events.set_basic_rate(frm, cdt, cdn);
},

View File

@@ -9,7 +9,7 @@ import frappe
from frappe import _, bold
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import DocType
from frappe.query_builder.functions import Max, Sum
from frappe.query_builder.functions import Coalesce, Max, Sum
from frappe.utils import (
cint,
comma_or,
@@ -92,7 +92,25 @@ def is_costed_out_of_finished_item(row) -> bool:
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.secondary_item_type and not row.bom_secondary_item))
return bool(
row.valuation_type in ("Valuation Rate", "Manual")
or (row.secondary_item_type and not row.bom_secondary_item)
)
def get_secondary_item_key(row):
"""Identity of a secondary output: its BOM row when linked, else (item, type).
Grouping only by (item, type) would merge rows that different BOMs of the same work
order produce, and one BOM row's percentage or valuation mode would then govern the
other BOMs' quantities too."""
if row.get("bom_secondary_item"):
return row.bom_secondary_item
return (
row.item_code,
row.secondary_item_type or ("Scrap" if row.get("valuation_type") == "Valuation Rate" else ""),
)
def _qty_tolerance(precision: int) -> float:
@@ -282,6 +300,7 @@ class StockEntry(StockController, SubcontractingInwardController):
if self.purpose in ("Manufacture", "Repack"):
self.mark_finished_and_secondary_items()
self.set_bomless_secondary_valuation_types()
if not self.job_card:
self.validate_finished_goods()
else:
@@ -977,7 +996,7 @@ class StockEntry(StockController, SubcontractingInwardController):
frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx))
if self.purpose in ["Manufacture", "Repack"]:
if d.is_finished_item or d.secondary_item_type or d.is_legacy_scrap_item:
if d.is_finished_item or d.secondary_item_type or d.valuation_type:
d.s_warehouse = None
if not d.t_warehouse:
frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx))
@@ -988,7 +1007,7 @@ class StockEntry(StockController, SubcontractingInwardController):
if self.purpose == "Disassemble":
if has_bom:
if d.is_finished_item or d.secondary_item_type or d.is_legacy_scrap_item:
if d.is_finished_item or d.secondary_item_type or d.valuation_type:
d.t_warehouse = None
if not d.s_warehouse:
frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx))
@@ -1587,9 +1606,16 @@ class StockEntry(StockController, SubcontractingInwardController):
items = []
# Set basic rate for incoming items
finished_items_last = sorted(self.get("items"), key=lambda row: cint(row.is_finished_item))
# Own-cost rows first: their value is deducted from the basis the percentage
# allocated rows and the finished good split, so it must be known before those.
finished_items_last = sorted(
self.get("items"),
key=lambda row: (cint(row.is_finished_item), cint(not is_costed_out_of_finished_item(row))),
)
for d in finished_items_last:
if d.s_warehouse or d.set_basic_rate_manually:
if d.set_basic_rate_manually:
d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount"))
continue
# Zero-qty secondary items carry no inventory value; skip rate calculation
@@ -1616,12 +1642,18 @@ class StockEntry(StockController, SubcontractingInwardController):
if self.bom_no:
d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100
elif is_costed_out_of_finished_item(d):
# Recomputed every time: a rate fetched before the target warehouse was set
# must not stick to the row.
d.basic_rate = self.get_row_valuation_rate(d, raise_error_if_no_rate)
has_derived_rate = True
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
allocation_basis = secondary_items_cost_basis - self.get_costed_out_items_cost()
d.basic_rate = (allocation_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
@@ -1630,18 +1662,7 @@ class StockEntry(StockController, SubcontractingInwardController):
if self.is_new():
raise_error_if_no_rate = False
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,
)
d.basic_rate = self.get_row_valuation_rate(d, raise_error_if_no_rate)
# do not round off basic rate to avoid precision loss
d.basic_rate = flt(d.basic_rate)
@@ -1661,6 +1682,20 @@ class StockEntry(StockController, SubcontractingInwardController):
frappe.msgprint(message, alert=True)
def get_row_valuation_rate(self, d, raise_error_if_no_rate):
return 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,
)
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")):
@@ -1722,6 +1757,8 @@ class StockEntry(StockController, SubcontractingInwardController):
)
def get_basic_rate_for_repacked_items(self, finished_item_qty, outgoing_items_cost):
outgoing_items_cost -= self.get_costed_out_items_cost()
finished_items = [
d.item_code for d in self.get("items") if d.is_finished_item and not d.set_basic_rate_manually
]
@@ -1739,6 +1776,36 @@ class StockEntry(StockController, SubcontractingInwardController):
)
return flt(outgoing_items_cost / total_fg_qty)
def set_bomless_secondary_valuation_types(self):
"""Secondary rows without a BOM link choose their own costing: valuation rate or manual.
There is no percentage to allocate without a BOM row, so % of FG Cost is rejected."""
for d in self.get("items"):
if d.bom_secondary_item:
continue
if not d.secondary_item_type:
if d.valuation_type:
d.valuation_type = ""
d.set_basic_rate_manually = 0
continue
if d.valuation_type == "% of FG Cost":
frappe.throw(
_(
"Row #{0}: % of FG Cost needs a BOM secondary item. Choose Valuation Rate or Manual for {1}."
).format(d.idx, frappe.bold(d.item_code))
)
if not d.valuation_type:
d.valuation_type = "Valuation Rate"
d.set_basic_rate_manually = cint(d.valuation_type == "Manual")
def get_costed_out_items_cost(self) -> float:
"""Total value of the rows that are deducted from the cost the other incoming rows split."""
return sum(flt(d.basic_amount) for d in self.get("items") if is_costed_out_of_finished_item(d))
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:
@@ -1773,9 +1840,7 @@ class StockEntry(StockController, SubcontractingInwardController):
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 = self.get_costed_out_items_cost()
if settings.material_consumption:
if settings.get_rm_cost_from_consumption_entry and self.work_order:
@@ -1785,7 +1850,7 @@ class StockEntry(StockController, SubcontractingInwardController):
if (
not item.is_finished_item
and not item.secondary_item_type
and not item.is_legacy_scrap_item
and not item.valuation_type
):
label = frappe.get_meta(settings.doctype).get_label(
"get_rm_cost_from_consumption_entry"
@@ -2192,7 +2257,7 @@ class StockEntry(StockController, SubcontractingInwardController):
for d in self.items:
if d.t_warehouse and not d.s_warehouse:
if d.secondary_item_type or d.is_legacy_scrap_item:
if d.secondary_item_type or d.valuation_type:
d.is_finished_item = 0
elif self.purpose == "Repack" or d.item_code == finished_item:
d.is_finished_item = 1
@@ -2943,7 +3008,7 @@ class StockEntry(StockController, SubcontractingInwardController):
"t_warehouse": t_warehouse,
"is_finished_item": source_row.is_finished_item,
"secondary_item_type": source_row.secondary_item_type,
"is_legacy_scrap_item": source_row.is_legacy_scrap_item,
"valuation_type": source_row.valuation_type,
"bom_secondary_item": source_row.bom_secondary_item,
"bom_no": source_row.bom_no,
# batch and serial bundles built on submit
@@ -3015,7 +3080,7 @@ class StockEntry(StockController, SubcontractingInwardController):
SED.conversion_factor,
SED.is_finished_item,
SED.secondary_item_type,
SED.is_legacy_scrap_item,
SED.valuation_type,
SED.bom_secondary_item,
SED.batch_no,
SED.serial_no,
@@ -3590,28 +3655,49 @@ class StockEntry(StockController, SubcontractingInwardController):
return
item_dict = {}
for row in self.get_secondary_items_from_job_card():
if row.stock_qty <= 0:
continue
item_dict[row.item_code] = frappe._dict(
rows = [row for row in self.get_secondary_items_from_job_card() if row.stock_qty > 0]
bom_rows = self.get_bom_secondary_item_details(rows)
for row in rows:
bom_row = bom_rows.get(row.bom_secondary_item, frappe._dict())
entry = frappe._dict(
{
"item_code": row.item_code,
"uom": row.stock_uom,
"from_warehouse": "",
"qty": row.stock_qty,
"conversion_factor": 1,
"secondary_item_type": row.secondary_item_type,
"valuation_type": bom_row.get("valuation_type"),
"item_name": row.item_name,
"description": row.description,
"bom_secondary_item": row.bom_secondary_item,
}
)
if entry.valuation_type == "Manual":
entry.manual_rate = (
flt(bom_row.cost) / flt(bom_row.stock_qty) if flt(bom_row.get("stock_qty")) else 0
)
item_dict[get_secondary_item_key(row)] = entry
for item in item_dict.values():
item.from_warehouse = ""
self.add_to_stock_entry_detail(item_dict)
def get_bom_secondary_item_details(self, secondary_items) -> dict:
names = [row.bom_secondary_item for row in secondary_items if row.bom_secondary_item]
if not names:
return {}
return {
row.name: row
for row in frappe.get_all(
"BOM Secondary Item",
filters={"name": ("in", names)},
fields=["name", "valuation_type", "cost", "stock_qty"],
)
}
def get_secondary_items_from_job_card(self):
if not hasattr(self, "pro_doc"):
self.pro_doc = None
@@ -3643,7 +3729,11 @@ class StockEntry(StockController, SubcontractingInwardController):
& (job_card.work_order == self.work_order)
& (job_card.docstatus == 1)
)
.groupby(job_card_secondary_item.item_code, job_card_secondary_item.secondary_item_type)
.groupby(
job_card_secondary_item.item_code,
job_card_secondary_item.secondary_item_type,
job_card_secondary_item.bom_secondary_item,
)
.orderby(job_card_secondary_item.idx)
)
@@ -3659,11 +3749,12 @@ class StockEntry(StockController, SubcontractingInwardController):
used_secondary_items = self.get_used_secondary_items()
for row in other:
row.stock_qty -= flt(used_secondary_items.get(row.item_code))
key = get_secondary_item_key(row)
row.stock_qty -= flt(used_secondary_items.get(key))
row.stock_qty = (row.stock_qty) * flt(self.fg_completed_qty) / flt(pending_qty)
if used_secondary_items.get(row.item_code):
used_secondary_items[row.item_code] -= row.stock_qty
if used_secondary_items.get(key):
used_secondary_items[key] -= row.stock_qty
if cint(frappe.get_cached_value("UOM", row.stock_uom, "must_be_whole_number")):
row.stock_qty = frappe.utils.ceil(row.stock_qty)
@@ -3682,12 +3773,18 @@ class StockEntry(StockController, SubcontractingInwardController):
frappe.qb.from_(StockEntry)
.inner_join(StockEntryDetail)
.on(StockEntryDetail.parent == StockEntry.name)
.select(StockEntryDetail.item_code, StockEntryDetail.qty)
.select(
StockEntryDetail.item_code,
StockEntryDetail.secondary_item_type,
StockEntryDetail.valuation_type,
StockEntryDetail.qty,
StockEntryDetail.bom_secondary_item,
)
.where(
(StockEntry.work_order == self.work_order)
& (
(StockEntryDetail.secondary_item_type.isnotnull())
| (StockEntryDetail.is_legacy_scrap_item == 1)
| (Coalesce(StockEntryDetail.valuation_type, "") != "")
)
& (StockEntry.docstatus == 1)
& (StockEntry.purpose.isin(["Repack", "Manufacture"]))
@@ -3695,7 +3792,7 @@ class StockEntry(StockController, SubcontractingInwardController):
).run(as_dict=1)
for row in data:
used_secondary_items[row.item_code] += row.qty
used_secondary_items[get_secondary_item_key(row)] += row.qty
return used_secondary_items
@@ -3993,7 +4090,7 @@ class StockEntry(StockController, SubcontractingInwardController):
not self.is_return
and child_qty <= 0
and not item_row.get("secondary_item_type")
and not item_row.get("is_legacy_scrap_item")
and not item_row.get("valuation_type")
):
if self.purpose not in ["Receive from Customer", "Send to Subcontractor"]:
continue
@@ -4017,7 +4114,10 @@ class StockEntry(StockController, SubcontractingInwardController):
se_child.scio_detail = item_row.get("scio_detail")
se_child.sample_quantity = item_row.get("sample_quantity", 0)
se_child.secondary_item_type = item_row.get("secondary_item_type")
se_child.is_legacy_scrap_item = item_row.get("is_legacy")
se_child.valuation_type = item_row.get("valuation_type")
if item_row.get("valuation_type") == "Manual":
se_child.set_basic_rate_manually = 1
se_child.basic_rate = flt(item_row.get("manual_rate"))
se_child.bom_secondary_item = item_row.get("name") or item_row.get("bom_secondary_item")
for field in [

View File

@@ -1005,8 +1005,9 @@ class TestStockEntry(ERPNextTestSuite):
rm_cost += d.amount
fg_cost = next(filter(lambda x: x.item_code == "_Test FG Item", s.get("items"))).amount
secondary_item_cost = next(
filter(lambda x: x.secondary_item_type or x.is_legacy_scrap_item, s.get("items"))
).amount
x.amount for x in s.get("items") if x.secondary_item_type or x.valuation_type
)
self.assertEqual(fg_cost, flt(rm_cost - secondary_item_cost, 2))
# When Stock Entry has only FG + Scrap
@@ -1086,6 +1087,356 @@ class TestStockEntry(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, ste.submit)
def test_manufacture_entry_with_valuation_rate_secondary_item(self):
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as _make_stock_entry,
)
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name
by_product = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item_code=rm_item, target="_Test Warehouse - _TC", qty=10, basic_rate=100)
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append(
"items",
{"item_code": rm_item, "qty": 10, "rate": 100.0, "source_warehouse": "_Test Warehouse - _TC"},
)
bom_doc.append(
"secondary_items",
{
"item_code": scrap_item,
"secondary_item_type": "Scrap",
"qty": 2,
"valuation_type": "Valuation Rate",
},
)
bom_doc.append(
"secondary_items",
{
"item_code": by_product,
"secondary_item_type": "By-Product",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
},
)
bom_doc.save()
bom_doc.submit()
work_order = frappe.new_doc("Work Order")
work_order.update(
{
"company": "_Test Company",
"fg_warehouse": "_Test Warehouse 1 - _TC",
"production_item": fg_item,
"bom_no": bom_doc.name,
"qty": 1.0,
"stock_uom": frappe.db.get_value("Item", fg_item, "stock_uom"),
"skip_transfer": 1,
}
)
work_order.get_items_and_operations_from_bom()
work_order.submit()
entry = frappe.get_doc(_make_stock_entry(work_order.name, "Manufacture", 1))
entry.insert()
rm_cost = sum(d.basic_amount for d in entry.items if d.s_warehouse)
self.assertEqual(rm_cost, 1000)
# valuation rate row is valued at its valuation rate and deducted from the
# basis; the percentage rows and the finished good split the remainder
scrap_row = next(d for d in entry.items if d.valuation_type == "Valuation Rate")
self.assertEqual(scrap_row.basic_rate, 50)
self.assertEqual(scrap_row.basic_amount, 100)
by_product_row = next(d for d in entry.items if d.secondary_item_type == "By-Product")
self.assertEqual(by_product_row.basic_amount, 90)
fg_row = next(d for d in entry.items if d.is_finished_item)
self.assertEqual(fg_row.basic_amount, 810)
incoming_cost = sum(d.basic_amount for d in entry.items if not d.s_warehouse)
self.assertEqual(incoming_cost, rm_cost)
# a stale rate, e.g. fetched before the target warehouse was set, must not stick
scrap_row.basic_rate = 999
entry.save()
scrap_row = next(d for d in entry.items if d.valuation_type == "Valuation Rate")
self.assertEqual(scrap_row.basic_rate, 50)
def test_manufacture_entry_with_same_item_secondary_types(self):
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as _make_stock_entry,
)
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
secondary_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name
make_stock_entry(item_code=rm_item, target="_Test Warehouse - _TC", qty=10, basic_rate=100)
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append(
"items",
{"item_code": rm_item, "qty": 10, "rate": 100.0, "source_warehouse": "_Test Warehouse - _TC"},
)
bom_doc.append(
"secondary_items",
{
"item_code": secondary_item,
"secondary_item_type": "Scrap",
"qty": 2,
"valuation_type": "Valuation Rate",
},
)
bom_doc.append(
"secondary_items",
{
"item_code": secondary_item,
"secondary_item_type": "By-Product",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
},
)
bom_doc.save()
bom_doc.submit()
work_order = frappe.new_doc("Work Order")
work_order.update(
{
"company": "_Test Company",
"fg_warehouse": "_Test Warehouse 1 - _TC",
"production_item": fg_item,
"bom_no": bom_doc.name,
"qty": 1.0,
"stock_uom": frappe.db.get_value("Item", fg_item, "stock_uom"),
"skip_transfer": 1,
}
)
work_order.get_items_and_operations_from_bom()
work_order.submit()
entry = frappe.get_doc(_make_stock_entry(work_order.name, "Manufacture", 1))
entry.insert()
# both rows of the same item keep their own type and costing mode
secondary_rows = [d for d in entry.items if d.item_code == secondary_item]
self.assertEqual(len(secondary_rows), 2)
scrap_row = next(d for d in secondary_rows if d.valuation_type == "Valuation Rate")
self.assertEqual(scrap_row.basic_amount, 100)
by_product_row = next(d for d in secondary_rows if d.valuation_type != "Valuation Rate")
self.assertEqual(by_product_row.secondary_item_type, "By-Product")
self.assertEqual(by_product_row.basic_amount, 90)
fg_row = next(d for d in entry.items if d.is_finished_item)
self.assertEqual(fg_row.basic_amount, 810)
def test_manufacture_entry_with_manual_secondary_item(self):
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as _make_stock_entry,
)
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
by_product = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item_code=rm_item, target="_Test Warehouse - _TC", qty=10, basic_rate=100)
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append(
"items",
{"item_code": rm_item, "qty": 10, "rate": 100.0, "source_warehouse": "_Test Warehouse - _TC"},
)
bom_doc.append(
"secondary_items",
{
"item_code": by_product,
"secondary_item_type": "By-Product",
"qty": 2,
"valuation_type": "Manual",
"cost": 120,
},
)
bom_doc.save()
bom_doc.submit()
work_order = frappe.new_doc("Work Order")
work_order.update(
{
"company": "_Test Company",
"fg_warehouse": "_Test Warehouse 1 - _TC",
"production_item": fg_item,
"bom_no": bom_doc.name,
"qty": 1.0,
"stock_uom": frappe.db.get_value("Item", fg_item, "stock_uom"),
"skip_transfer": 1,
}
)
work_order.get_items_and_operations_from_bom()
work_order.submit()
entry = frappe.get_doc(_make_stock_entry(work_order.name, "Manufacture", 1))
entry.insert()
# the manual row starts at the BOM cost per unit and is deducted from the FG
manual_row = next(d for d in entry.items if d.valuation_type == "Manual")
self.assertEqual(manual_row.set_basic_rate_manually, 1)
self.assertEqual(manual_row.basic_rate, 60)
self.assertEqual(manual_row.basic_amount, 120)
fg_row = next(d for d in entry.items if d.is_finished_item)
self.assertEqual(fg_row.basic_amount, 880)
# the user's own rate reprices the row and the finished good
manual_row.basic_rate = 100
entry.save()
fg_row = next(d for d in entry.items if d.is_finished_item)
self.assertEqual(fg_row.basic_amount, 800)
# a manual cost above the consumed cost would turn the finished good negative
manual_row = next(d for d in entry.items if d.valuation_type == "Manual")
manual_row.basic_rate = 600
self.assertRaises(frappe.ValidationError, entry.save)
def test_repack_entry_with_valuation_rate_secondary_item(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name
make_stock_entry(item_code=rm_item, target="_Test Warehouse - _TC", qty=10, basic_rate=100)
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 10, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": scrap_item,
"secondary_item_type": "Scrap",
"qty": 2,
"valuation_type": "Valuation Rate",
},
)
bom_doc.save()
bom_doc.submit()
entry = frappe.new_doc("Stock Entry")
entry.company = "_Test Company"
entry.purpose = "Repack"
entry.set_stock_entry_type()
entry.from_bom = 1
entry.bom_no = bom_doc.name
entry.fg_completed_qty = 1
entry.from_warehouse = "_Test Warehouse - _TC"
entry.to_warehouse = "_Test Warehouse 1 - _TC"
entry.get_items()
entry.insert()
# the repacked good absorbs the consumed cost net of the own-cost rows
scrap_row = next(d for d in entry.items if d.valuation_type == "Valuation Rate")
self.assertEqual(scrap_row.basic_amount, 100)
fg_row = next(d for d in entry.items if d.is_finished_item)
self.assertEqual(fg_row.basic_amount, 900)
outgoing = sum(d.basic_amount for d in entry.items if d.s_warehouse)
incoming = sum(d.basic_amount for d in entry.items if not d.s_warehouse)
self.assertEqual(incoming, outgoing)
def test_bomless_manufacture_entry_secondary_valuation_types(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1}).name
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name
manual_item = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item_code=rm_item, target="_Test Warehouse - _TC", qty=10, basic_rate=100)
def row(item_code, qty, **kwargs):
stock_uom = frappe.db.get_value("Item", item_code, "stock_uom")
return {
"item_code": item_code,
"qty": qty,
"transfer_qty": qty,
"uom": stock_uom,
"stock_uom": stock_uom,
"conversion_factor": 1,
**kwargs,
}
entry = frappe.new_doc("Stock Entry")
entry.company = "_Test Company"
entry.purpose = "Manufacture"
entry.set_stock_entry_type()
entry.fg_completed_qty = 1
entry.append("items", row(rm_item, 10, s_warehouse="_Test Warehouse - _TC"))
entry.append("items", row(fg_item, 1, t_warehouse="_Test Warehouse 1 - _TC", is_finished_item=1))
entry.append(
"items",
row(scrap_item, 2, t_warehouse="_Test Warehouse 1 - _TC", secondary_item_type="Scrap"),
)
entry.append(
"items",
row(
manual_item,
1,
t_warehouse="_Test Warehouse 1 - _TC",
secondary_item_type="By-Product",
valuation_type="Manual",
basic_rate=70,
),
)
entry.insert()
# without a BOM link, the valuation type defaults to Valuation Rate
scrap_row = next(d for d in entry.items if d.item_code == scrap_item)
self.assertEqual(scrap_row.valuation_type, "Valuation Rate")
self.assertEqual(scrap_row.basic_rate, 50)
# a manual row keeps the user's rate
manual_row = next(d for d in entry.items if d.item_code == manual_item)
self.assertTrue(manual_row.set_basic_rate_manually)
self.assertEqual(manual_row.basic_amount, 70)
# both are deducted from the finished good
fg_row = next(d for d in entry.items if d.is_finished_item)
self.assertEqual(fg_row.basic_amount, 830)
# there is no percentage to allocate without a BOM row
manual_row.valuation_type = "% of FG Cost"
self.assertRaises(frappe.ValidationError, entry.save)
def test_valuation_rate_lookup_without_voucher_no(self):
from erpnext.stock.stock_ledger import get_valuation_rate
item = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item_code=item, target="_Test Warehouse - _TC", qty=5, basic_rate=77)
# unsaved documents pass no voucher_no; the lookup must still find the last SLE
rate = get_valuation_rate(
item, "_Test Warehouse - _TC", "Stock Entry", None, raise_error_if_no_rate=False
)
self.assertEqual(rate, 77)
def test_quality_check_for_secondary_item(self):
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as _make_stock_entry,
@@ -1124,7 +1475,7 @@ class TestStockEntry(ERPNextTestSuite):
basic_rate=row.basic_rate or 100,
)
if row.secondary_item_type or row.is_legacy_scrap_item:
if row.secondary_item_type or row.valuation_type:
row.item_code = secondary_item
row.uom = frappe.db.get_value("Item", secondary_item, "stock_uom")
row.stock_uom = frappe.db.get_value("Item", secondary_item, "stock_uom")
@@ -1133,15 +1484,11 @@ class TestStockEntry(ERPNextTestSuite):
stock_entry.save()
self.assertTrue(
[
row.item_code
for row in stock_entry.items
if row.secondary_item_type or row.is_legacy_scrap_item
]
[row.item_code for row in stock_entry.items if row.secondary_item_type or row.valuation_type]
)
for row in stock_entry.items:
if not row.secondary_item_type and not row.is_legacy_scrap_item:
if not row.secondary_item_type and not row.valuation_type:
qc = frappe.get_doc(
{
"doctype": "Quality Inspection",
@@ -1161,7 +1508,7 @@ class TestStockEntry(ERPNextTestSuite):
stock_entry.reload()
stock_entry.submit()
for row in stock_entry.items:
if row.secondary_item_type or row.is_legacy_scrap_item:
if row.secondary_item_type or row.valuation_type:
self.assertFalse(row.quality_inspection)
else:
self.assertTrue(row.quality_inspection)
@@ -2821,6 +3168,7 @@ class TestStockEntry(ERPNextTestSuite):
"qty": 5,
"cost_allocation_per": 25,
"process_loss_per": 0,
"valuation_type": "% of FG Cost",
},
)
bom.insert()
@@ -2882,6 +3230,7 @@ class TestStockEntry(ERPNextTestSuite):
"qty": 5,
"cost_allocation_per": 0,
"process_loss_per": 0,
"valuation_type": "% of FG Cost",
},
)
bom.insert()
@@ -2968,6 +3317,7 @@ class TestStockEntry(ERPNextTestSuite):
"qty": 5,
"cost_allocation_per": 25,
"process_loss_per": 0,
"valuation_type": "% of FG Cost",
},
)
bom.insert()

View File

@@ -18,7 +18,7 @@
"item_name",
"col_break2",
"is_finished_item",
"is_legacy_scrap_item",
"valuation_type",
"secondary_item_type",
"quality_inspection",
"subcontracted_item",
@@ -570,7 +570,7 @@
},
{
"default": "0",
"depends_on": "eval:!doc.is_legacy_scrap_item && !doc.secondary_item_type",
"depends_on": "eval:!doc.valuation_type && !doc.secondary_item_type",
"fieldname": "is_finished_item",
"fieldtype": "Check",
"label": "Is Finished Item",
@@ -663,11 +663,12 @@
"set_only_once": 1
},
{
"depends_on": "eval:parent.purpose == \"Manufacture\" && doc.t_warehouse && !doc.is_finished_item && !doc.is_legacy_scrap_item",
"depends_on": "eval:parent.purpose == \"Manufacture\" && doc.t_warehouse && !doc.is_finished_item",
"fieldname": "secondary_item_type",
"fieldtype": "Select",
"label": "Type",
"options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good"
"options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good",
"read_only_depends_on": "eval:doc.bom_secondary_item"
},
{
"fieldname": "bom_secondary_item",
@@ -677,12 +678,12 @@
"read_only": 1
},
{
"default": "0",
"depends_on": "is_legacy_scrap_item",
"fieldname": "is_legacy_scrap_item",
"fieldtype": "Check",
"label": "Is Legacy Scrap Item",
"read_only": 1
"depends_on": "secondary_item_type",
"fieldname": "valuation_type",
"fieldtype": "Select",
"label": "Valuation Type",
"options": "\nValuation Rate\n% of FG Cost\nManual",
"read_only_depends_on": "eval:!doc.secondary_item_type || doc.bom_secondary_item"
}
],
"grid_page_length": 50,

View File

@@ -35,7 +35,6 @@ class StockEntryDetail(Document):
has_item_scanned: DF.Check
image: DF.Attach | None
is_finished_item: DF.Check
is_legacy_scrap_item: DF.Check
item_code: DF.Link
item_group: DF.Data | None
item_name: DF.Data | None
@@ -70,6 +69,7 @@ class StockEntryDetail(Document):
transferred_qty: DF.Float
secondary_item_type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
uom: DF.Link
valuation_type: DF.Literal["", "Valuation Rate", "% of FG Cost", "Manual"]
use_serial_batch_fields: DF.Check
valuation_rate: DF.Currency
# end: auto-generated types

View File

@@ -2029,9 +2029,11 @@ def get_valuation_rate(
& (table.warehouse == warehouse)
& (table.batch_no == batch_no)
& (table.is_cancelled == 0)
& ((table.voucher_no != voucher_no) | (table.voucher_type != voucher_type))
)
)
if voucher_no:
# Comparing against a None voucher_no yields NULL, which filters out every row
query = query.where((table.voucher_no != voucher_no) | (table.voucher_type != voucher_type))
last_valuation_rate = query.run()
if last_valuation_rate and last_valuation_rate[0][0] is not None:
@@ -2056,17 +2058,24 @@ def get_valuation_rate(
return batch_obj.get_incoming_rate()
# Get valuation rate from last sle for the same item and warehouse
exclude_voucher_condition = ""
values = [item_code, warehouse]
if voucher_no:
# Comparing against a None voucher_no yields NULL, which filters out every row
exclude_voucher_condition = "AND NOT (voucher_no = %s AND voucher_type = %s)"
values.extend([voucher_no, voucher_type])
if last_valuation_rate := frappe.db.sql( # nosemgrep
"""select valuation_rate
f"""select valuation_rate
from `tabStock Ledger Entry`
where
item_code = %s
AND warehouse = %s
AND valuation_rate >= 0
AND is_cancelled = 0
AND NOT (voucher_no = %s AND voucher_type = %s)
{exclude_voucher_condition}
order by posting_datetime desc, creation desc limit 1""",
(item_code, warehouse, voucher_no, voucher_type),
values,
):
return flt(last_valuation_rate[0][0])

View File

@@ -144,11 +144,13 @@ class SubcontractingReceipt(SubcontractingController):
super().validate()
if self.is_new() and self.get("_action") == "save" and not frappe.in_test:
self.get_secondary_items()
self.set_missing_values()
# after set_missing_values, so the secondary rates are computed from the same
# calculated per-qty costs the Get Secondary Items button uses
if self.is_new() and self.get("_action") == "save" and not frappe.in_test:
self.get_secondary_items(recalculate_rate=True)
if self.get("_action") == "submit":
self.validate_secondary_items()
self.validate_accepted_warehouse()
@@ -382,74 +384,140 @@ class SubcontractingReceipt(SubcontractingController):
for item in list(self.items):
if item.bom:
bom = frappe.get_doc("BOM", item.bom)
for secondary_item in bom.secondary_items:
per_unit = secondary_item.stock_qty / bom.quantity
received_qty = flt(item.received_qty * per_unit, item.precision("received_qty"))
qty = flt(
item.received_qty * (per_unit - (secondary_item.process_loss_qty / bom.quantity)),
item.precision("qty"),
)
if not secondary_item.is_legacy:
lcv_cost_per_qty = (
flt(item.landed_cost_voucher_amount) / flt(item.qty) if flt(item.qty) else 0.0
)
fg_item_cost = (
flt(item.rm_cost_per_qty)
+ flt(item.secondary_items_cost_per_qty)
+ flt(item.additional_cost_per_qty)
+ flt(lcv_cost_per_qty)
+ flt(item.service_cost_per_qty)
) * flt(item.received_qty)
rate = (
(item.amount if self.is_new() else fg_item_cost)
* (secondary_item.cost_allocation_per / 100)
) / qty
else:
rate = (
get_valuation_rate(
secondary_item.item_code,
self.set_warehouse,
self.doctype,
self.name,
currency=erpnext.get_company_currency(self.company),
company=self.company,
)
or secondary_item.rate
)
self.append(
"items",
{
"secondary_item_type": secondary_item.secondary_item_type,
"is_legacy_scrap_item": secondary_item.is_legacy,
"reference_name": item.name,
"item_code": secondary_item.item_code,
"item_name": secondary_item.item_name,
"qty": received_qty
if not secondary_item.is_legacy
else flt(item.qty) * (flt(secondary_item.stock_qty) / flt(bom.quantity)),
"received_qty": received_qty,
"process_loss_qty": received_qty - qty,
"stock_uom": secondary_item.stock_uom,
"rate": rate,
"rm_cost_per_qty": 0,
"service_cost_per_qty": 0,
"additional_cost_per_qty": 0,
"secondary_items_cost_per_qty": 0,
"amount": qty * rate,
"warehouse": self.set_warehouse,
"rejected_warehouse": self.rejected_warehouse,
},
)
self.add_secondary_items_of_fg_row(item)
if recalculate_rate:
self.calculate_additional_costs()
self.calculate_items_qty_and_amount()
def calculate_percentage_secondary_rows(self, percentage_rows, secondary_items_cost_map):
allocation_map = {}
names = [row.bom_secondary_item for row in percentage_rows if row.bom_secondary_item]
if names:
allocation_map = dict(
frappe.get_all(
"BOM Secondary Item",
filters={"name": ("in", names)},
fields=["name", "cost_allocation_per"],
as_list=True,
)
)
fg_rows = {row.name: row for row in self.get("items") if row.bom}
for item in percentage_rows:
qty = flt(item.received_qty) - flt(item.process_loss_qty)
fg_row = fg_rows.get(item.reference_name)
if qty and fg_row and item.bom_secondary_item in allocation_map:
item.rate = self.get_percentage_secondary_rate(
fg_row,
flt(allocation_map[item.bom_secondary_item]),
qty,
secondary_items_cost_map.get(item.reference_name, 0),
)
item.amount = qty * flt(item.rate)
def get_secondary_item_valuation_rate(self, item):
"""Valuation at the row's warehouse; keeps the stored rate when none is found."""
return (
get_valuation_rate(
item.item_code,
item.warehouse or self.set_warehouse,
self.doctype,
self.name,
currency=erpnext.get_company_currency(self.company),
company=self.company,
)
or item.rate
)
def add_secondary_items_of_fg_row(self, item):
"""Own-cost rows first: the percentage rows allocate from the cost net of them."""
bom = frappe.get_doc("BOM", item.bom)
warehouse = self.set_warehouse or item.warehouse
own_cost = 0.0
percentage_rows = []
for secondary_item in bom.secondary_items:
if secondary_item.valuation_type in ("Valuation Rate", "Manual"):
row = self.append_secondary_item(item, bom, secondary_item, warehouse)
own_cost += flt(row.qty) * flt(row.rate)
else:
percentage_rows.append(secondary_item)
for secondary_item in percentage_rows:
self.append_secondary_item(item, bom, secondary_item, warehouse, own_cost)
def append_secondary_item(self, item, bom, secondary_item, warehouse, own_cost=0.0):
per_unit = secondary_item.stock_qty / bom.quantity
received_qty = flt(item.received_qty * per_unit, item.precision("received_qty"))
qty = flt(
item.received_qty * (per_unit - (secondary_item.process_loss_qty / bom.quantity)),
item.precision("qty"),
)
rate = self.get_secondary_item_rate(item, secondary_item, warehouse, qty, own_cost)
return self.append(
"items",
{
"secondary_item_type": secondary_item.secondary_item_type,
"valuation_type": secondary_item.valuation_type,
"bom_secondary_item": secondary_item.name,
"reference_name": item.name,
"item_code": secondary_item.item_code,
"item_name": secondary_item.item_name,
"qty": received_qty
if secondary_item.valuation_type not in ("Valuation Rate", "Manual")
else flt(item.qty) * (flt(secondary_item.stock_qty) / flt(bom.quantity)),
"received_qty": received_qty,
"process_loss_qty": received_qty - qty,
"stock_uom": secondary_item.stock_uom,
"rate": rate,
"rm_cost_per_qty": 0,
"service_cost_per_qty": 0,
"additional_cost_per_qty": 0,
"secondary_items_cost_per_qty": 0,
"amount": qty * rate,
"warehouse": warehouse,
"rejected_warehouse": self.rejected_warehouse,
},
)
def get_secondary_item_rate(self, item, secondary_item, warehouse, qty, own_cost):
if secondary_item.valuation_type == "Manual":
if not flt(secondary_item.stock_qty):
return 0
return flt(secondary_item.cost) / flt(secondary_item.stock_qty)
if secondary_item.valuation_type == "Valuation Rate":
rate = get_valuation_rate(
secondary_item.item_code,
warehouse,
self.doctype,
self.name,
currency=erpnext.get_company_currency(self.company),
company=self.company,
)
if not rate and secondary_item.stock_qty:
rate = flt(secondary_item.cost) / flt(secondary_item.stock_qty)
return rate
return self.get_percentage_secondary_rate(item, secondary_item.cost_allocation_per, qty, own_cost)
def get_percentage_secondary_rate(self, fg_row, cost_allocation_per, qty, own_cost):
lcv_cost_per_qty = (
flt(fg_row.landed_cost_voucher_amount) / flt(fg_row.qty) if flt(fg_row.qty) else 0.0
)
fg_item_cost = (
flt(fg_row.rm_cost_per_qty)
+ flt(fg_row.additional_cost_per_qty)
+ flt(lcv_cost_per_qty)
+ flt(fg_row.service_cost_per_qty)
) * flt(fg_row.received_qty) - flt(own_cost)
return (fg_item_cost * (cost_allocation_per / 100)) / qty
def remove_secondary_items(self):
for item in list(self.items):
if item.secondary_item_type or item.is_legacy_scrap_item:
if item.secondary_item_type or item.valuation_type:
self.remove(item)
else:
item.secondary_items_cost_per_qty = 0
@@ -507,24 +575,31 @@ class SubcontractingReceipt(SubcontractingController):
else:
rm_cost_map[item.reference_name] = item.amount
# own-cost rows first: they are deducted from the finished good, and the
# percentage rows reprice from the basis net of them
secondary_items_cost_map = {}
percentage_rows = []
for item in self.get("items") or []:
if item.secondary_item_type or item.is_legacy_scrap_item:
qty = (
flt(item.qty)
if item.is_legacy_scrap_item
else (flt(item.received_qty) - flt(item.process_loss_qty))
)
item.amount = qty * flt(item.rate)
if not (item.secondary_item_type or item.valuation_type):
continue
if item.reference_name in secondary_items_cost_map:
secondary_items_cost_map[item.reference_name] += item.amount
else:
secondary_items_cost_map[item.reference_name] = item.amount
if item.valuation_type in ("Valuation Rate", "Manual"):
if item.valuation_type == "Valuation Rate":
# Recomputed every time: a rate fetched against another warehouse
# must not stick to the row when the warehouse changes.
item.rate = self.get_secondary_item_valuation_rate(item)
item.amount = flt(item.qty) * flt(item.rate)
secondary_items_cost_map[item.reference_name] = (
secondary_items_cost_map.get(item.reference_name, 0) + item.amount
)
else:
percentage_rows.append(item)
self.calculate_percentage_secondary_rows(percentage_rows, secondary_items_cost_map)
total_qty = total_amount = 0
for item in self.get("items") or []:
if not item.secondary_item_type and not item.is_legacy_scrap_item:
if not item.secondary_item_type and not item.valuation_type:
if item.qty:
if item.name in rm_cost_map:
item.rm_supp_cost = rm_cost_map[item.name]
@@ -546,6 +621,7 @@ class SubcontractingReceipt(SubcontractingController):
+ flt(item.service_cost_per_qty)
+ flt(item.additional_cost_per_qty)
+ flt(lcv_cost_per_qty)
- flt(item.secondary_items_cost_per_qty)
)
if item.bom:
@@ -568,7 +644,7 @@ class SubcontractingReceipt(SubcontractingController):
def validate_secondary_items(self):
for item in self.items:
if item.secondary_item_type or item.is_legacy_scrap_item:
if item.secondary_item_type or item.valuation_type:
if not item.qty:
frappe.throw(
_("Row #{0}: Secondary Item Qty cannot be zero").format(item.idx),

View File

@@ -1193,11 +1193,33 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
"secondary_items",
{
"item_code": item,
"secondary_item_type": "Scrap",
"stock_qty": 1 * (idx + 1),
"rate": 10 * (idx + 1),
"is_legacy": 1,
"valuation_type": "Valuation Rate",
},
)
manual_item = make_item(properties={"is_stock_item": 1}).name
bom.append(
"secondary_items",
{
"item_code": manual_item,
"secondary_item_type": "By-Product",
"stock_qty": 1,
"valuation_type": "Manual",
"cost": 30,
},
)
percentage_item = make_item(properties={"is_stock_item": 1}).name
bom.append(
"secondary_items",
{
"item_code": percentage_item,
"secondary_item_type": "Co-Product",
"stock_qty": 1,
"valuation_type": "% of FG Cost",
"cost_allocation_per": 10,
},
)
bom.save()
bom.submit()
@@ -1221,10 +1243,72 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
scr.get_secondary_items()
scr_secondary_items = set(
[item.item_code for item in scr.items if item.secondary_item_type or item.is_legacy_scrap_item]
[item.item_code for item in scr.items if item.secondary_item_type or item.valuation_type]
)
self.assertEqual(len(scr.items), 5) # 1 FG Item + 4 Secondary Items
self.assertEqual(scr_secondary_items, {*secondary_items, manual_item, percentage_item})
# without a document level warehouse the rows fall back to the FG row's warehouse
scr.set_warehouse = None
scr.get_secondary_items()
fg_warehouse = next(item.warehouse for item in scr.items if item.bom)
for item in scr.items:
if item.secondary_item_type or item.valuation_type:
self.assertEqual(item.warehouse, fg_warehouse)
# the percentage row allocates from the cost net of the own-cost rows, so the
# received value equals the consumed value
scr.save()
fg_row = next(item for item in scr.items if item.bom)
fg_gross = (
flt(fg_row.rm_cost_per_qty)
+ flt(fg_row.service_cost_per_qty)
+ flt(fg_row.additional_cost_per_qty)
) * flt(fg_row.received_qty)
secondary_total = sum(
flt(row.amount) for row in scr.items if row.secondary_item_type or row.valuation_type
)
self.assertAlmostEqual(flt(fg_row.amount) + secondary_total, fg_gross, places=2)
# valuation rate rows are repriced at their warehouse on every save
make_stock_entry(item_code=secondary_item_1, target="_Test Warehouse - _TC", qty=5, basic_rate=40)
vr_row = next(item for item in scr.items if item.item_code == secondary_item_1)
vr_row.warehouse = "_Test Warehouse - _TC"
scr.save()
vr_row = next(item for item in scr.items if item.item_code == secondary_item_1)
self.assertEqual(vr_row.rate, 40)
# the manual row starts at the BOM cost per unit and takes the user's own rate
manual_row = next(item for item in scr.items if item.item_code == manual_item)
self.assertEqual(manual_row.rate, 30)
manual_row.rate = 45
scr.save()
manual_row = next(item for item in scr.items if item.item_code == manual_item)
self.assertEqual(manual_row.rate, 45)
self.assertEqual(manual_row.amount, 45 * manual_row.qty)
# percentage rows reprice on save when the own-cost basis changes
own_total = sum(
flt(row.amount) for row in scr.items if row.valuation_type in ("Valuation Rate", "Manual")
)
percentage_row = next(item for item in scr.items if item.item_code == percentage_item)
self.assertAlmostEqual(flt(percentage_row.amount), (fg_gross - own_total) * 0.10, places=2)
# the finished good's rate is its cost allocation share of the net cost
fg_row = next(item for item in scr.items if item.bom)
self.assertTrue(fg_row.secondary_items_cost_per_qty > 0)
fg_percent = flt(frappe.get_value("BOM", fg_row.bom, "cost_allocation_per")) / 100
self.assertAlmostEqual(
flt(fg_row.rate),
(
flt(fg_row.rm_cost_per_qty)
+ flt(fg_row.service_cost_per_qty)
+ flt(fg_row.additional_cost_per_qty)
- flt(fg_row.secondary_items_cost_per_qty)
)
* fg_percent,
places=2,
)
self.assertEqual(len(scr.items), 3) # 1 FG Item + 2 Scrap Items
self.assertEqual(scr_secondary_items, set(secondary_items))
scr.submit()

View File

@@ -8,7 +8,8 @@
"engine": "InnoDB",
"field_order": [
"item_code",
"is_legacy_scrap_item",
"valuation_type",
"bom_secondary_item",
"secondary_item_type",
"column_break_2",
"item_name",
@@ -162,12 +163,12 @@
"label": "Accepted Qty",
"no_copy": 1,
"print_width": "100px",
"read_only_depends_on": "eval:doc.secondary_item_type || doc.is_legacy_scrap_item",
"read_only_depends_on": "eval:doc.secondary_item_type || doc.valuation_type",
"width": "100px"
},
{
"columns": 1,
"depends_on": "eval:!parent.is_return && !doc.secondary_item_type && !doc.is_legacy_scrap_item",
"depends_on": "eval:!parent.is_return && !doc.secondary_item_type && !doc.valuation_type",
"fieldname": "rejected_qty",
"fieldtype": "Float",
"in_list_view": 1,
@@ -175,7 +176,7 @@
"no_copy": 1,
"print_hide": 1,
"print_width": "100px",
"read_only_depends_on": "eval:doc.secondary_item_type || doc.is_legacy_scrap_item",
"read_only_depends_on": "eval:doc.secondary_item_type || doc.valuation_type",
"width": "100px"
},
{
@@ -216,9 +217,10 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Rate",
"non_negative": 1,
"options": "Company:company:default_currency",
"print_width": "100px",
"read_only": 1,
"read_only_depends_on": "eval:doc.valuation_type != 'Manual'",
"width": "100px"
},
{
@@ -235,7 +237,7 @@
},
{
"default": "0",
"depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item",
"depends_on": "eval:!doc.secondary_item_type && !doc.valuation_type",
"fieldname": "rm_cost_per_qty",
"fieldtype": "Currency",
"label": "Raw Material Cost Per Qty",
@@ -245,7 +247,7 @@
},
{
"default": "0",
"depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item",
"depends_on": "eval:!doc.secondary_item_type && !doc.valuation_type",
"fieldname": "service_cost_per_qty",
"fieldtype": "Currency",
"label": "Service Cost Per Qty",
@@ -255,7 +257,7 @@
},
{
"default": "0",
"depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item",
"depends_on": "eval:!doc.secondary_item_type && !doc.valuation_type",
"fieldname": "additional_cost_per_qty",
"fieldtype": "Currency",
"label": "Additional Cost Per Qty",
@@ -279,7 +281,7 @@
"width": "100px"
},
{
"depends_on": "eval: !parent.is_return && !doc.secondary_item_type && !doc.is_legacy_scrap_item",
"depends_on": "eval: !parent.is_return && !doc.secondary_item_type && !doc.valuation_type",
"fieldname": "rejected_warehouse",
"fieldtype": "Link",
"ignore_user_permissions": 1,
@@ -291,7 +293,7 @@
"width": "100px"
},
{
"depends_on": "eval:!doc.__islocal && !doc.secondary_item_type && !doc.is_legacy_scrap_item",
"depends_on": "eval:!doc.__islocal && !doc.secondary_item_type && !doc.valuation_type",
"fieldname": "quality_inspection",
"fieldtype": "Link",
"label": "Quality Inspection",
@@ -373,7 +375,7 @@
"no_copy": 1,
"options": "BOM",
"print_hide": 1,
"read_only_depends_on": "eval:doc.secondary_item_type || doc.is_legacy_scrap_item"
"read_only_depends_on": "eval:doc.secondary_item_type || doc.valuation_type"
},
{
"fetch_from": "item_code.brand",
@@ -500,7 +502,7 @@
"print_hide": 1
},
{
"depends_on": "eval:(doc.use_serial_batch_fields === 0 || doc.docstatus === 1) && !doc.secondary_item_type && !doc.is_legacy_scrap_item",
"depends_on": "eval:(doc.use_serial_batch_fields === 0 || doc.docstatus === 1) && !doc.secondary_item_type && !doc.valuation_type",
"fieldname": "rejected_serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Rejected Serial and Batch Bundle",
@@ -565,7 +567,7 @@
"label": "Add Serial / Batch Bundle"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 0 && !doc.secondary_item_type && !doc.is_legacy_scrap_item",
"depends_on": "eval:doc.use_serial_batch_fields === 0 && !doc.secondary_item_type && !doc.valuation_type",
"fieldname": "add_serial_batch_for_rejected_qty",
"fieldtype": "Button",
"label": "Add Serial / Batch No (Rejected Qty)"
@@ -579,7 +581,7 @@
"search_index": 1
},
{
"depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item",
"depends_on": "eval:!doc.secondary_item_type && !doc.valuation_type",
"fieldname": "landed_cost_voucher_amount",
"fieldtype": "Currency",
"label": "Landed Cost Voucher Amount",
@@ -607,7 +609,7 @@
},
{
"default": "0",
"depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item",
"depends_on": "eval:!doc.secondary_item_type && !doc.valuation_type",
"fieldname": "secondary_items_cost_per_qty",
"fieldtype": "Currency",
"label": "Secondary Items Cost Per Qty",
@@ -617,11 +619,18 @@
"read_only": 1
},
{
"default": "0",
"depends_on": "is_legacy_scrap_item",
"fieldname": "is_legacy_scrap_item",
"fieldtype": "Check",
"label": "Is Legacy Scrap Item",
"depends_on": "valuation_type",
"fieldname": "valuation_type",
"fieldtype": "Select",
"label": "Valuation Type",
"options": "\nValuation Rate\n% of FG Cost\nManual",
"read_only": 1
},
{
"fieldname": "bom_secondary_item",
"fieldtype": "Data",
"hidden": 1,
"label": "BOM Secondary Item",
"read_only": 1
},
{

View File

@@ -17,6 +17,7 @@ class SubcontractingReceiptItem(Document):
additional_cost_per_qty: DF.Currency
amount: DF.Currency
batch_no: DF.Link | None
bom_secondary_item: DF.Data | None
bom: DF.Link | None
brand: DF.Link | None
conversion_factor: DF.Float
@@ -25,7 +26,6 @@ class SubcontractingReceiptItem(Document):
expense_account: DF.Link | None
image: DF.Attach | None
include_exploded_items: DF.Check
is_legacy_scrap_item: DF.Check
item_code: DF.Link
item_name: DF.Data | None
job_card: DF.Link | None
@@ -64,6 +64,7 @@ class SubcontractingReceiptItem(Document):
subcontracting_receipt_item: DF.Data | None
secondary_item_type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
use_serial_batch_fields: DF.Check
valuation_type: DF.Literal["", "Valuation Rate", "% of FG Cost", "Manual"]
warehouse: DF.Link | None
# end: auto-generated types