feat: co product by product support (#52979) (#53975)

This commit is contained in:
Mihir Kandoi
2026-04-01 13:11:39 +05:30
committed by GitHub
parent e230f72e0b
commit 8db397bdae
53 changed files with 1492 additions and 699 deletions

View File

@@ -1435,7 +1435,7 @@ class StockController(AccountsController):
elif self.doctype == "Stock Entry" and row.t_warehouse:
qi_required = True # inward stock needs inspection
if row.get("is_scrap_item"):
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

View File

@@ -160,7 +160,7 @@ class SubcontractingController(StockController):
).format(item.idx, get_link_to_form("Item", item.item_code))
)
if not item.get("is_scrap_item"):
if not item.get("type") and not item.get("is_legacy_scrap_item"):
if not is_sub_contracted_item:
frappe.throw(
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
@@ -206,7 +206,7 @@ class SubcontractingController(StockController):
).format(item.idx, item.item_name)
)
if self.doctype != "Subcontracting Inward Order":
if self.doctype not in ["Subcontracting Inward Order", "Subcontracting Receipt"]:
item.amount = item.qty * item.rate
if item.bom:
@@ -238,7 +238,7 @@ class SubcontractingController(StockController):
and self._doc_before_save
):
for row in self._doc_before_save.get("items"):
item_dict[row.name] = (row.item_code, row.qty + (row.get("rejected_qty") or 0))
item_dict[row.name] = (row.item_code, row.received_qty)
return item_dict
@@ -264,7 +264,7 @@ class SubcontractingController(StockController):
self.__reference_name.append(row.name)
if (row.name not in item_dict) or (
row.item_code,
row.qty + (row.get("rejected_qty") or 0),
row.received_qty,
) != item_dict[row.name]:
self.__changed_name.append(row.name)
@@ -962,7 +962,7 @@ class SubcontractingController(StockController):
):
qty = (
flt(bom_item.qty_consumed_per_unit)
* flt(row.qty + (row.get("rejected_qty") or 0))
* flt(row.get("received_qty") or (row.qty + (row.get("rejected_qty") or 0)))
* row.conversion_factor
)
bom_item.main_item_code = row.item_code
@@ -1285,22 +1285,28 @@ class SubcontractingController(StockController):
if self.total_additional_costs:
if self.distribute_additional_costs_based_on == "Amount":
total_amt = sum(
flt(item.amount) for item in self.get("items") if not item.get("is_scrap_item")
flt(item.amount)
for item in self.get("items")
if not item.get("type") and not item.get("is_legacy_scrap_item")
)
for item in self.items:
if not item.get("is_scrap_item"):
if not item.get("type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = (
(item.amount * self.total_additional_costs) / total_amt
) / item.qty
else:
total_qty = sum(flt(item.qty) for item in self.get("items") if not item.get("is_scrap_item"))
total_qty = sum(
flt(item.qty)
for item in self.get("items")
if not item.get("type") and not item.get("is_legacy_scrap_item")
)
additional_cost_per_qty = self.total_additional_costs / total_qty
for item in self.items:
if not item.get("is_scrap_item"):
if not item.get("type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = additional_cost_per_qty
else:
for item in self.items:
if not item.get("is_scrap_item"):
if not item.get("type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = 0
@frappe.whitelist()

View File

@@ -1,3 +1,5 @@
from collections import defaultdict
import frappe
from frappe import _, bold
from frappe.query_builder import Case
@@ -18,7 +20,7 @@ class SubcontractingInwardController:
def on_submit_subcontracting_inward(self):
self.update_inward_order_item()
self.update_inward_order_received_items()
self.update_inward_order_scrap_items()
self.update_inward_order_secondary_items()
self.create_stock_reservation_entries_for_inward()
self.update_inward_order_status()
@@ -28,7 +30,7 @@ class SubcontractingInwardController:
self.validate_delivery()
self.validate_receive_from_customer_cancel()
self.update_inward_order_received_items()
self.update_inward_order_scrap_items()
self.update_inward_order_secondary_items()
self.remove_reference_for_additional_items()
self.update_inward_order_status()
@@ -239,7 +241,8 @@ class SubcontractingInwardController:
item
for item in self.get("items")
if not item.is_finished_item
and not item.is_scrap_item
and not item.type
and not item.is_legacy_scrap_item
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
]
@@ -368,7 +371,9 @@ class SubcontractingInwardController:
if self.subcontracting_inward_order:
if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]:
for item in self.items:
if (item.is_finished_item or item.is_scrap_item) and item.valuation_rate == 0:
if (
item.is_finished_item or item.type or item.is_legacy_scrap_item
) and item.valuation_rate == 0:
item.allow_zero_valuation_rate = 1
def validate_warehouse_(self):
@@ -467,7 +472,7 @@ class SubcontractingInwardController:
self.validate_delivery_on_save()
else:
for item in self.items:
if not item.is_scrap_item:
if not item.type and not item.is_legacy_scrap_item:
delivered_qty, returned_qty = frappe.get_value(
"Subcontracting Inward Order Item",
item.scio_detail,
@@ -519,7 +524,7 @@ class SubcontractingInwardController:
if max_allowed_qty:
max_allowed_qty = max_allowed_qty[0]
else:
table = frappe.qb.DocType("Subcontracting Inward Order Scrap Item")
table = frappe.qb.DocType("Subcontracting Inward Order Secondary Item")
query = (
frappe.qb.from_(table)
.select((table.produced_qty - table.delivered_qty).as_("max_allowed_qty"))
@@ -538,8 +543,8 @@ class SubcontractingInwardController:
bold(
frappe.get_cached_value(
"Subcontracting Inward Order Item"
if not item.is_scrap_item
else "Subcontracting Inward Order Scrap Item",
if not item.type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Secondary Item",
item.scio_detail,
"stock_uom",
)
@@ -590,9 +595,9 @@ class SubcontractingInwardController:
)
for item in [item for item in self.items if not item.is_finished_item]:
if item.is_scrap_item:
scio_scrap_item = frappe.get_value(
"Subcontracting Inward Order Scrap Item",
if item.type or item.is_legacy_scrap_item:
scio_secondary_item = frappe.get_value(
"Subcontracting Inward Order Secondary Item",
{
"docstatus": 1,
"item_code": item.item_code,
@@ -603,12 +608,13 @@ class SubcontractingInwardController:
as_dict=True,
)
if (
scio_scrap_item
and scio_scrap_item.delivered_qty > scio_scrap_item.produced_qty - item.transfer_qty
scio_secondary_item
and scio_secondary_item.delivered_qty
> scio_secondary_item.produced_qty - item.transfer_qty
):
frappe.throw(
_(
"Row #{0}: Cannot cancel this Manufacturing Stock Entry as quantity of Scrap Item {1} produced cannot be less than quantity delivered."
"Row #{0}: Cannot cancel this Manufacturing Stock Entry as quantity of Secondary Item {1} produced cannot be less than quantity delivered."
).format(item.idx, get_link_to_form("Item", item.item_code))
)
else:
@@ -648,8 +654,8 @@ class SubcontractingInwardController:
for item in self.items:
doctype = (
"Subcontracting Inward Order Item"
if not item.is_scrap_item
else "Subcontracting Inward Order Scrap Item"
if not item.type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Secondary Item"
)
frappe.db.set_value(
doctype,
@@ -763,7 +769,11 @@ class SubcontractingInwardController:
customer_warehouse = frappe.get_cached_value(
"Subcontracting Inward Order", self.subcontracting_inward_order, "customer_warehouse"
)
items = [item for item in self.items if not item.is_finished_item and not item.is_scrap_item]
items = [
item
for item in self.items
if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item
]
item_code_wh = frappe._dict(
{
(
@@ -860,24 +870,24 @@ class SubcontractingInwardController:
doc.insert()
doc.submit()
def update_inward_order_scrap_items(self):
def update_inward_order_secondary_items(self):
if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture":
scrap_items_list = [item for item in self.items if item.is_scrap_item]
scrap_items = frappe._dict(
{
(item.item_code, item.t_warehouse): item.transfer_qty
if self._action == "submit"
else -item.transfer_qty
for item in scrap_items_list
}
)
if scrap_items:
item_codes, warehouses = zip(*list(scrap_items.keys()), strict=True)
secondary_items_list = [item for item in self.items if item.type or item.is_legacy_scrap_item]
secondary_items = defaultdict(float)
for item in secondary_items_list:
secondary_items[(item.item_code, item.t_warehouse)] += (
item.transfer_qty if self._action == "submit" else -item.transfer_qty
)
secondary_items = frappe._dict(secondary_items)
if secondary_items:
item_codes, warehouses = zip(*list(secondary_items.keys()), strict=True)
item_codes = list(item_codes)
warehouses = list(warehouses)
result = frappe.get_all(
"Subcontracting Inward Order Scrap Item",
"Subcontracting Inward Order Secondary Item",
filters={
"item_code": ["in", item_codes],
"warehouse": ["in", warehouses],
@@ -890,7 +900,7 @@ class SubcontractingInwardController:
)
if result:
scrap_item_dict = frappe._dict(
secondary_items_dict = frappe._dict(
{
(d.item_code, d.warehouse): frappe._dict(
{"name": d.name, "produced_qty": d.produced_qty}
@@ -900,40 +910,45 @@ class SubcontractingInwardController:
)
deleted_docs = []
case_expr = Case()
table = frappe.qb.DocType("Subcontracting Inward Order Scrap Item")
for key, value in scrap_item_dict.items():
if self._action == "cancel" and value.produced_qty - abs(scrap_items.get(key)) == 0:
table = frappe.qb.DocType("Subcontracting Inward Order Secondary Item")
for key, value in secondary_items_dict.items():
if (
self._action == "cancel"
and value.produced_qty - abs(secondary_items.get(key)) == 0
):
deleted_docs.append(value.name)
frappe.delete_doc("Subcontracting Inward Order Scrap Item", value.name)
frappe.delete_doc("Subcontracting Inward Order Secondary Item", value.name)
else:
case_expr = case_expr.when(
table.name == value.name, value.produced_qty + scrap_items.get(key)
table.name == value.name, value.produced_qty + secondary_items.get(key)
)
if final_list := list(
set([v.name for v in scrap_item_dict.values()]) - set(deleted_docs)
set([v.name for v in secondary_items_dict.values()]) - set(deleted_docs)
):
frappe.qb.update(table).set(table.produced_qty, case_expr).where(
(table.name.isin(final_list)) & (table.docstatus == 1)
).run()
fg_item_code = next(fg for fg in self.items if fg.is_finished_item).item_code
for scrap_item in [
for secondary_item in [
item
for item in scrap_items_list
for item in secondary_items_list
if (item.item_code, item.t_warehouse) not in [(d.item_code, d.warehouse) for d in result]
]:
doc = frappe.new_doc(
"Subcontracting Inward Order Scrap Item",
"Subcontracting Inward Order Secondary Item",
parent=scio,
parenttype="Subcontracting Inward Order",
parentfield="scrap_items",
idx=frappe.db.count("Subcontracting Inward Order Scrap Item", {"parent": scio}) + 1,
item_code=scrap_item.item_code,
parentfield="secondary_items",
idx=frappe.db.count("Subcontracting Inward Order Secondary Item", {"parent": scio})
+ 1,
item_code=secondary_item.item_code,
fg_item_code=fg_item_code,
stock_uom=scrap_item.stock_uom,
warehouse=scrap_item.t_warehouse,
produced_qty=scrap_item.transfer_qty,
stock_uom=secondary_item.stock_uom,
warehouse=secondary_item.t_warehouse,
produced_qty=secondary_item.transfer_qty,
type=secondary_item.type,
delivered_qty=0,
reference_name=frappe.get_value(
"Work Order", self.work_order, "subcontracting_inward_order_item"
@@ -965,7 +980,7 @@ class SubcontractingInwardController:
and (
not frappe.db.exists("Subcontracting Inward Order Received Item", item.scio_detail)
and not frappe.db.exists("Subcontracting Inward Order Item", item.scio_detail)
and not frappe.db.exists("Subcontracting Inward Order Scrap Item", item.scio_detail)
and not frappe.db.exists("Subcontracting Inward Order Secondary Item", item.scio_detail)
)
]
for item in items:

View File

@@ -501,8 +501,8 @@ class TestSubcontractingController(ERPNextTestSuite):
scr1.items[0].qty = 2
add_second_row_in_scr(scr1)
scr1.flags.ignore_mandatory = True
scr1.save()
scr1.set_missing_values()
scr1.save()
scr1.submit()
for _key, value in get_supplied_items(scr1).items():
@@ -513,8 +513,8 @@ class TestSubcontractingController(ERPNextTestSuite):
scr2.items[0].qty = 2
add_second_row_in_scr(scr2)
scr2.flags.ignore_mandatory = True
scr2.save()
scr2.set_missing_values()
scr2.save()
scr2.submit()
for _key, value in get_supplied_items(scr2).items():
@@ -523,8 +523,8 @@ class TestSubcontractingController(ERPNextTestSuite):
scr3 = make_subcontracting_receipt(sco.name)
scr3.items[0].qty = 2
scr3.flags.ignore_mandatory = True
scr3.save()
scr3.set_missing_values()
scr3.save()
scr3.submit()
for _key, value in get_supplied_items(scr3).items():
@@ -1164,6 +1164,54 @@ class TestSubcontractingController(ERPNextTestSuite):
self.assertEqual([item.rm_item_code for item in sco.supplied_items], expected)
def test_co_by_product(self):
frappe.set_value("UOM", "Nos", "must_be_whole_number", 0)
fg_item = make_item("FG Item", properties={"is_stock_item": 1, "is_sub_contracted_item": 1}).name
rm_item = make_item("RM Item", properties={"is_stock_item": 1}).name
scrap_item = make_item("Scrap Item", properties={"is_stock_item": 1}).name
make_bom(
item=fg_item, raw_materials=[rm_item], scrap_items=[scrap_item], process_loss_percentage=10
).name
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 11",
"qty": 5,
"rate": 100,
"fg_item": fg_item,
"fg_item_qty": 5,
},
]
sco = get_subcontracting_order(service_items=service_items)
rm_items = get_rm_items(sco.supplied_items)
itemwise_details = make_stock_in_entry(rm_items=rm_items)
make_stock_transfer_entry(
sco_no=sco.name,
rm_items=rm_items,
itemwise_details=copy.deepcopy(itemwise_details),
)
scr1 = make_subcontracting_receipt(sco.name)
scr1.get_secondary_items()
scr1.save()
self.assertEqual(scr1.items[0].received_qty, 5)
self.assertEqual(scr1.items[0].process_loss_qty, 0.5)
self.assertEqual(scr1.items[0].qty, 4.5)
self.assertEqual(scr1.items[0].rate, 200)
self.assertEqual(scr1.items[0].amount, 900)
self.assertEqual(scr1.items[1].item_code, scrap_item)
self.assertEqual(scr1.items[1].received_qty, 5)
self.assertEqual(scr1.items[1].process_loss_qty, 0.5)
self.assertEqual(scr1.items[1].qty, 4.5)
self.assertEqual(flt(scr1.items[1].rate, 3), 11.111)
self.assertEqual(scr1.items[1].amount, 50)
frappe.set_value("UOM", "Nos", "must_be_whole_number", 1)
def add_second_row_in_scr(scr):
item_dict = {}

View File

@@ -620,10 +620,10 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
}
item_code(doc, cdt, cdn) {
var scrap_items = false;
let secondary_items = false;
var child = locals[cdt][cdn];
if (child.doctype == "BOM Scrap Item") {
scrap_items = true;
if (child.doctype == "BOM Secondary Item") {
secondary_items = true;
}
if (child.bom_no) {
@@ -634,7 +634,7 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
child.do_not_explode = 1;
}
get_bom_material_detail(doc, cdt, cdn, scrap_items);
get_bom_material_detail(doc, cdt, cdn, secondary_items);
}
buying_price_list(doc) {
@@ -683,7 +683,7 @@ cur_frm.cscript.is_default = function (doc) {
if (doc.is_default) cur_frm.set_value("is_active", 1);
};
var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) {
var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
if (!doc.company) {
frappe.throw({ message: __("Please select a Company first."), title: __("Mandatory") });
}
@@ -697,7 +697,6 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) {
company: doc.company,
item_code: d.item_code,
bom_no: d.bom_no != null ? d.bom_no : "",
scrap_items: scrap_items,
qty: d.qty,
stock_qty: d.stock_qty,
include_item_in_manufacturing: d.include_item_in_manufacturing,
@@ -706,15 +705,15 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) {
conversion_factor: d.conversion_factor,
sourced_by_supplier: d.sourced_by_supplier,
do_not_explode: d.do_not_explode,
fetch_rate: !secondary_items,
},
callback: function (r) {
$.extend(d, r.message);
refresh_field("items");
refresh_field("scrap_items");
refresh_field("secondary_items");
doc = locals[doc.doctype][doc.name];
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(doc);
},
freeze: true,
@@ -724,20 +723,18 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) {
cur_frm.cscript.qty = function (doc) {
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(doc);
};
cur_frm.cscript.rate = function (doc, cdt, cdn) {
var d = locals[cdt][cdn];
const is_scrap_item = cdt == "BOM Scrap Item";
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_scrap_item);
get_bom_material_detail(doc, cdt, cdn, is_secondary_item);
} else {
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(doc);
}
};
@@ -745,7 +742,6 @@ cur_frm.cscript.rate = function (doc, cdt, cdn) {
erpnext.bom.update_cost = function (doc) {
erpnext.bom.calculate_op_cost(doc);
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(doc);
};
@@ -804,34 +800,11 @@ erpnext.bom.calculate_rm_cost = function (doc) {
cur_frm.set_value("base_raw_material_cost", base_total_rm_cost);
};
// sm : scrap material
erpnext.bom.calculate_scrap_materials_cost = function (doc) {
var sm = doc.scrap_items || [];
var total_sm_cost = 0;
var base_total_sm_cost = 0;
for (var i = 0; i < sm.length; i++) {
var base_rate = flt(sm[i].rate) * flt(doc.conversion_rate);
var amount = flt(sm[i].rate) * flt(sm[i].stock_qty);
var base_amount = amount * flt(doc.conversion_rate);
frappe.model.set_value("BOM Scrap Item", sm[i].name, "base_rate", base_rate);
frappe.model.set_value("BOM Scrap Item", sm[i].name, "amount", amount);
frappe.model.set_value("BOM Scrap Item", sm[i].name, "base_amount", base_amount);
total_sm_cost += amount;
base_total_sm_cost += base_amount;
}
cur_frm.set_value("scrap_material_cost", total_sm_cost);
cur_frm.set_value("base_scrap_material_cost", base_total_sm_cost);
};
// Calculate Total Cost
erpnext.bom.calculate_total = function (doc) {
var total_cost = flt(doc.operating_cost) + flt(doc.raw_material_cost) - flt(doc.scrap_material_cost);
var total_cost = flt(doc.operating_cost) + flt(doc.raw_material_cost) - flt(doc.secondary_items_cost);
var base_total_cost =
flt(doc.base_operating_cost) + flt(doc.base_raw_material_cost) - flt(doc.base_scrap_material_cost);
flt(doc.base_operating_cost) + flt(doc.base_raw_material_cost) - flt(doc.base_secondary_items_cost);
cur_frm.set_value("total_cost", total_cost);
cur_frm.set_value("base_total_cost", base_total_cost);
@@ -986,7 +959,7 @@ frappe.tour["BOM"] = [
},
];
frappe.ui.form.on("BOM Scrap Item", {
frappe.ui.form.on("BOM Secondary Item", {
item_code(frm, cdt, cdn) {
const { item_code } = locals[cdt][cdn];
},
@@ -1007,7 +980,7 @@ function trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code) {
const row = locals[cdt][cdn];
row.stock_qty = (frm.doc.quantity * data.percent) / 100;
row.qty = row.stock_qty / (row.conversion_factor || 1);
refresh_field("scrap_items");
refresh_field("secondary_items");
},
__("Set Process Loss Item Quantity"),
__("Set Quantity")

View File

@@ -16,6 +16,14 @@
"allow_alternative_item",
"set_rate_of_sub_assembly_item_based_on_bom",
"is_phantom_bom",
"cost_allocation_section",
"cost_allocation_per",
"column_break_srby",
"cost_allocation",
"process_loss_section",
"process_loss_percentage",
"column_break_ssj2",
"process_loss_qty",
"currency_detail",
"rm_cost_as_per",
"buying_price_list",
@@ -38,21 +46,16 @@
"operations",
"materials_section",
"items",
"scrap_section",
"scrap_items_section",
"scrap_items",
"process_loss_section",
"process_loss_percentage",
"column_break_ssj2",
"process_loss_qty",
"secondary_items_tab",
"secondary_items",
"costing",
"operating_cost",
"raw_material_cost",
"scrap_material_cost",
"secondary_items_cost",
"cb1",
"base_operating_cost",
"base_raw_material_cost",
"base_scrap_material_cost",
"base_secondary_items_cost",
"column_break_26",
"total_cost",
"base_total_cost",
@@ -298,19 +301,6 @@
"options": "BOM Item",
"reqd": 1
},
{
"collapsible": 1,
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "scrap_section",
"fieldtype": "Tab Break",
"label": "Scrap & Process Loss"
},
{
"fieldname": "scrap_items",
"fieldtype": "Table",
"label": "Scrap Items",
"options": "BOM Scrap Item"
},
{
"fieldname": "costing",
"fieldtype": "Tab Break",
@@ -332,15 +322,6 @@
"options": "currency",
"read_only": 1
},
{
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "scrap_material_cost",
"fieldtype": "Currency",
"label": "Scrap Material Cost",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "cb1",
"fieldtype": "Column Break"
@@ -362,15 +343,6 @@
"print_hide": 1,
"read_only": 1
},
{
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "base_scrap_material_cost",
"fieldtype": "Currency",
"label": "Scrap Material Cost(Company Currency)",
"no_copy": 1,
"options": "Company:company:default_currency",
"read_only": 1
},
{
"fieldname": "total_cost",
"fieldtype": "Currency",
@@ -602,12 +574,6 @@
"fieldname": "column_break_ivyw",
"fieldtype": "Column Break"
},
{
"fieldname": "scrap_items_section",
"fieldtype": "Section Break",
"hide_border": 1,
"label": "Scrap Items"
},
{
"default": "0",
"fieldname": "fg_based_operating_cost",
@@ -706,6 +672,59 @@
"fieldname": "quality_inspection_tab",
"fieldtype": "Tab Break",
"label": "Quality Inspection"
},
{
"fieldname": "secondary_items",
"fieldtype": "Table",
"label": "Secondary Items",
"options": "BOM Secondary Item"
},
{
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "secondary_items_cost",
"fieldtype": "Currency",
"label": "Secondary Items Cost",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
{
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "base_secondary_items_cost",
"fieldtype": "Currency",
"label": "Secondary Items Cost (Company Currency)",
"no_copy": 1,
"options": "Company:company:default_currency",
"read_only": 1
},
{
"fieldname": "secondary_items_tab",
"fieldtype": "Tab Break",
"label": "Secondary Items"
},
{
"fieldname": "cost_allocation_section",
"fieldtype": "Section Break",
"label": "Cost Allocation"
},
{
"fieldname": "column_break_srby",
"fieldtype": "Column Break"
},
{
"fieldname": "cost_allocation",
"fieldtype": "Currency",
"label": "Cost Allocation",
"non_negative": 1,
"options": "currency",
"read_only": 1
},
{
"default": "100",
"fieldname": "cost_allocation_per",
"fieldtype": "Percent",
"label": "% Cost Allocation",
"non_negative": 1
}
],
"icon": "fa fa-sitemap",
@@ -713,7 +732,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2026-02-06 17:23:15.255301",
"modified": "2026-02-26 14:13:34.040181",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM",

View File

@@ -113,19 +113,21 @@ class BOM(WebsiteGenerator):
from erpnext.manufacturing.doctype.bom_explosion_item.bom_explosion_item import BOMExplosionItem
from erpnext.manufacturing.doctype.bom_item.bom_item import BOMItem
from erpnext.manufacturing.doctype.bom_operation.bom_operation import BOMOperation
from erpnext.manufacturing.doctype.bom_scrap_item.bom_scrap_item import BOMScrapItem
from erpnext.manufacturing.doctype.bom_secondary_item.bom_secondary_item import BOMSecondaryItem
allow_alternative_item: DF.Check
amended_from: DF.Link | None
base_operating_cost: DF.Currency
base_raw_material_cost: DF.Currency
base_scrap_material_cost: DF.Currency
base_secondary_items_cost: DF.Currency
base_total_cost: DF.Currency
bom_creator: DF.Link | None
bom_creator_item: DF.Data | None
buying_price_list: DF.Link | None
company: DF.Link
conversion_rate: DF.Float
cost_allocation: DF.Currency
cost_allocation_per: DF.Percent
currency: DF.Link
default_source_warehouse: DF.Link | None
default_target_warehouse: DF.Link | None
@@ -155,8 +157,8 @@ class BOM(WebsiteGenerator):
rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List"]
route: DF.SmallText | None
routing: DF.Link | None
scrap_items: DF.Table[BOMScrapItem]
scrap_material_cost: DF.Currency
secondary_items: DF.Table[BOMSecondaryItem]
secondary_items_cost: DF.Currency
set_rate_of_sub_assembly_item_based_on_bom: DF.Check
show_in_website: DF.Check
show_items: DF.Check
@@ -284,7 +286,7 @@ class BOM(WebsiteGenerator):
self.set_plc_conversion_rate()
self.validate_uom_is_interger()
self.set_bom_material_details()
self.set_bom_scrap_items_detail()
self.set_secondary_items_details()
self.validate_materials()
self.validate_transfer_against()
self.set_routing_operations()
@@ -294,9 +296,12 @@ class BOM(WebsiteGenerator):
self.update_stock_qty()
self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False)
self.set_process_loss_qty()
self.validate_scrap_items()
self.validate_uoms()
self.set_default_uom()
self.validate_semi_finished_goods()
self.validate_secondary_items()
self.set_fg_cost_allocation()
self.validate_total_cost_allocation()
if self.docstatus == 1:
self.validate_raw_materials_of_operation()
@@ -326,6 +331,22 @@ class BOM(WebsiteGenerator):
),
)
def validate_secondary_items(self):
for item in self.secondary_items:
if not item.qty:
frappe.throw(
_("Row #{0}: Quantity should be greater than 0 for {1} Item {2}").format(
item.idx, item.type, get_link_to_form("Item", item.item_code)
)
)
if item.process_loss_per >= 100:
frappe.throw(
_("Row #{0}: Process Loss Percentage should be less than 100% for {1} Item {2}").format(
item.idx, item.type, get_link_to_form("Item", item.item_code)
)
)
def validate_raw_materials_of_operation(self):
if not self.track_semi_finished_goods or not self.operations:
return
@@ -401,6 +422,24 @@ class BOM(WebsiteGenerator):
doc = frappe.get_doc("BOM Creator", self.bom_creator)
doc.set_status(save=True)
def set_fg_cost_allocation(self):
total_secondary_items_per = 0
for item in self.secondary_items:
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)
def validate_total_cost_allocation(self):
total_cost_allocation_per = self.cost_allocation_per
for item in self.secondary_items:
total_cost_allocation_per += item.cost_allocation_per
if total_cost_allocation_per != 100:
frappe.throw(_("Cost allocation between finished goods and secondary items should equal 100%"))
def on_update_after_submit(self):
self.validate_bom_links()
self.manage_default_bom()
@@ -462,6 +501,7 @@ class BOM(WebsiteGenerator):
"conversion_factor": item.conversion_factor,
"sourced_by_supplier": item.sourced_by_supplier,
"do_not_explode": item.do_not_explode,
"fetch_rate": True,
}
)
@@ -469,13 +509,13 @@ class BOM(WebsiteGenerator):
if not item.get(r):
item.set(r, ret[r])
def set_bom_scrap_items_detail(self):
for item in self.get("scrap_items"):
def set_secondary_items_details(self):
for item in self.get("secondary_items"):
args = {
"item_code": item.item_code,
"company": self.company,
"scrap_items": True,
"bom_no": "",
"uom": item.uom,
"fetch_rate": False,
}
ret = self.get_bom_material_detail(args)
for key, value in ret.items():
@@ -495,7 +535,7 @@ class BOM(WebsiteGenerator):
item = self.get_item_det(args["item_code"])
args["bom_no"] = args["bom_no"] or item and cstr(item["default_bom"]) or ""
args["bom_no"] = args.get("bom_no") or item and cstr(item["default_bom"]) or ""
args["transfer_for_manufacture"] = (
cstr(args.get("include_item_in_manufacturing", ""))
or item
@@ -504,7 +544,7 @@ class BOM(WebsiteGenerator):
)
args.update(item)
rate = self.get_rm_rate(args)
rate = self.get_rm_rate(args) if args.get("fetch_rate") else 0
ret_item = {
"item_name": item and args["item_name"] or "",
"description": item and args["description"] or "",
@@ -546,9 +586,7 @@ class BOM(WebsiteGenerator):
if not self.rm_cost_as_per:
self.rm_cost_as_per = "Valuation Rate"
if arg.get("scrap_items"):
rate = get_valuation_rate(arg)
elif arg:
if 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"
@@ -688,7 +726,7 @@ class BOM(WebsiteGenerator):
)
def update_stock_qty(self):
for m in self.get("items"):
for m in self.get("items") + self.get("secondary_items"):
if not m.conversion_factor:
m.conversion_factor = flt(get_conversion_factor(m.item_code, m.uom)["conversion_factor"])
if m.uom and m.qty:
@@ -889,16 +927,16 @@ class BOM(WebsiteGenerator):
"""Calculate bom totals"""
self.calculate_op_cost(update_hour_rate)
self.calculate_rm_cost(save=save_updates)
self.calculate_sm_cost(save=save_updates)
self.calculate_secondary_items_costs(save=save_updates)
if save_updates:
# not via doc event, table is not regenerated and needs updation
self.calculate_exploded_cost()
old_cost = self.total_cost
self.total_cost = self.operating_cost + self.raw_material_cost - self.scrap_material_cost
self.total_cost = self.operating_cost + self.raw_material_cost - self.secondary_items_cost
self.base_total_cost = (
self.base_operating_cost + self.base_raw_material_cost - self.base_scrap_material_cost
self.base_operating_cost + self.base_raw_material_cost - self.base_secondary_items_cost
)
if self.total_cost != old_cost:
@@ -997,29 +1035,24 @@ class BOM(WebsiteGenerator):
self.raw_material_cost = total_rm_cost
self.base_raw_material_cost = base_total_rm_cost
def calculate_sm_cost(self, save=False):
def calculate_secondary_items_costs(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals"""
total_sm_cost = 0
base_total_sm_cost = 0
precision = self.precision("raw_material_cost")
for d in self.get("scrap_items"):
d.base_rate = flt(d.rate, d.precision("rate")) * flt(
self.conversion_rate, self.precision("conversion_rate")
)
d.amount = flt(
flt(d.rate, d.precision("rate")) * flt(d.stock_qty, d.precision("stock_qty")),
d.precision("amount"),
)
d.base_amount = flt(d.amount, d.precision("amount")) * flt(
self.conversion_rate, self.precision("conversion_rate")
)
total_sm_cost += d.amount
base_total_sm_cost += d.base_amount
if save:
d.db_update()
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)
d.base_cost = flt(d.cost * self.conversion_rate, precision)
self.scrap_material_cost = total_sm_cost
self.base_scrap_material_cost = base_total_sm_cost
total_sm_cost += d.cost
base_total_sm_cost += d.base_cost
if save:
d.db_update()
self.secondary_items_cost = total_sm_cost
self.base_secondary_items_cost = base_total_sm_cost
def calculate_exploded_cost(self):
"Set exploded row cost from it's parent BOM."
@@ -1221,16 +1254,29 @@ class BOM(WebsiteGenerator):
if self.process_loss_percentage:
self.process_loss_qty = flt(self.quantity) * flt(self.process_loss_percentage) / 100
def validate_scrap_items(self):
must_be_whole_number = frappe.get_value("UOM", self.uom, "must_be_whole_number")
for item in self.secondary_items:
item.process_loss_qty = flt(
item.stock_qty * (item.process_loss_per / 100), self.precision("quantity")
)
if self.process_loss_percentage and self.process_loss_percentage > 100:
def validate_uoms(self):
self.validate_uom(self.item, self.uom, self.process_loss_percentage, self.process_loss_qty)
for item in self.secondary_items:
self.validate_uom(item.item_code, item.stock_uom, item.process_loss_per, item.process_loss_qty)
def validate_uom(self, item_code, uom, process_loss_per, process_loss_qty):
must_be_whole_number = frappe.get_value("UOM", uom, "must_be_whole_number")
if process_loss_per and process_loss_per > 100:
frappe.throw(_("Process Loss Percentage cannot be greater than 100"))
if self.process_loss_qty and must_be_whole_number and self.process_loss_qty % 1 != 0:
msg = f"Item: {frappe.bold(self.item)} with Stock UOM: {frappe.bold(self.uom)} can't have fractional process loss qty as UOM {frappe.bold(self.uom)} is a whole Number."
if process_loss_qty and must_be_whole_number and process_loss_qty % 1 != 0:
msg = f"Item: {frappe.bold(item_code)} with Stock UOM: {frappe.bold(uom)} can't have fractional process loss qty as UOM {frappe.bold(uom)} is a whole Number."
frappe.throw(msg, title=_("Invalid Process Loss Configuration"))
def has_scrap_items(self):
return any(d.get("type") == "Scrap" or d.get("is_legacy") for d in self.get("secondary_items"))
def get_bom_item_rate(args, bom_doc):
if bom_doc.rm_cost_as_per == "Valuation Rate":
@@ -1332,7 +1378,7 @@ def get_bom_items_as_dict(
company,
qty=1,
fetch_exploded=1,
fetch_scrap_items=0,
fetch_secondary_items=0,
include_non_stock_items=False,
fetch_qty_in_stock_uom=True,
):
@@ -1343,7 +1389,7 @@ def get_bom_items_as_dict(
fetch_exploded = 0
group_by_cond = "group by item_code, operation_row_id, stock_uom"
if fetch_scrap_items:
if fetch_secondary_items:
fetch_exploded = 0
group_by_cond = "group by item_code"
@@ -1355,8 +1401,6 @@ def get_bom_items_as_dict(
sum(bom_item.{qty_field}/ifnull(bom.quantity, 1)) * %(qty)s as qty,
item.image,
bom.project,
bom_item.rate,
sum(bom_item.{qty_field}/ifnull(bom.quantity, 1)) * bom_item.rate * %(qty)s as amount,
item.stock_uom,
item.item_group,
item.allow_alternative_item,
@@ -1388,17 +1432,18 @@ def get_bom_items_as_dict(
group_by_cond=group_by_cond,
select_columns=""", bom_item.source_warehouse, bom_item.operation,
bom_item.include_item_in_manufacturing, bom_item.description, bom_item.rate, bom_item.sourced_by_supplier,
sum(bom_item.stock_qty/ifnull(bom.quantity, 1)) * bom_item.rate * %(qty)s as amount,
(Select idx from `tabBOM Item` where item_code = bom_item.item_code and parent = %(parent)s limit 1) as idx""",
)
items = frappe.db.sql(
query, {"parent": bom, "qty": qty, "bom": bom, "company": company}, as_dict=True
)
elif fetch_scrap_items:
elif fetch_secondary_items:
query = query.format(
table="BOM Scrap Item",
table="BOM Secondary Item",
where_conditions=")",
select_columns=", item.description",
select_columns=", item.description, bom_item.cost_allocation_per, bom_item.process_loss_per, bom_item.type, bom_item.name, bom_item.is_legacy",
is_stock_item=is_stock_item,
qty_field="stock_qty",
group_by_cond=group_by_cond,
@@ -1411,8 +1456,9 @@ def get_bom_items_as_dict(
where_conditions="or bom_item.is_phantom_item)",
is_stock_item=is_stock_item,
qty_field="stock_qty" if fetch_qty_in_stock_uom else "qty",
select_columns=""", bom_item.uom, bom_item.conversion_factor, bom_item.source_warehouse,
select_columns=""", bom_item.rate, bom_item.uom, bom_item.conversion_factor, bom_item.source_warehouse,
bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.sourced_by_supplier,
sum(bom_item.stock_qty/ifnull(bom.quantity, 1)) * bom_item.rate * %(qty)s as amount,
bom_item.description, bom_item.base_rate as rate, bom_item.operation_row_id, bom_item.is_phantom_item , bom_item.bom_no """,
group_by_cond=group_by_cond,
)
@@ -1432,7 +1478,7 @@ def get_bom_items_as_dict(
company,
qty=item.get("qty"),
fetch_exploded=fetch_exploded,
fetch_scrap_items=fetch_scrap_items,
fetch_secondary_items=fetch_secondary_items,
include_non_stock_items=include_non_stock_items,
fetch_qty_in_stock_uom=fetch_qty_in_stock_uom,
)
@@ -1482,7 +1528,7 @@ def validate_bom_no(item, bom_no):
for d in bom.items:
if d.item_code.lower() == item.lower():
rm_item_exists = True
for d in bom.scrap_items:
for d in bom.secondary_items:
if d.item_code.lower() == item.lower():
rm_item_exists = True
if (
@@ -1773,7 +1819,7 @@ def get_bom_diff(bom1, bom2):
identifiers = {
"operations": "operation",
"items": "item_code",
"scrap_items": "item_code",
"secondary_items": "item_code",
"exploded_items": "item_code",
}
@@ -1919,9 +1965,9 @@ def get_op_cost_from_sub_assemblies(bom_no, op_cost=0):
return op_cost
def get_scrap_items_from_sub_assemblies(bom_no, company, qty, scrap_items=None):
if not scrap_items:
scrap_items = {}
def get_secondary_items_from_sub_assemblies(bom_no, company, qty, secondary_items=None):
if not secondary_items:
secondary_items = {}
bom_items = frappe.get_all(
"BOM Item",
@@ -1935,9 +1981,9 @@ def get_scrap_items_from_sub_assemblies(bom_no, company, qty, scrap_items=None):
continue
qty = flt(row.qty) * flt(qty)
items = get_bom_items_as_dict(row.bom_no, company, qty=qty, fetch_exploded=0, fetch_scrap_items=1)
scrap_items.update(items)
items = get_bom_items_as_dict(row.bom_no, company, qty=qty, fetch_exploded=0, fetch_secondary_items=1)
secondary_items.update(items)
get_scrap_items_from_sub_assemblies(row.bom_no, company, qty, scrap_items)
get_secondary_items_from_sub_assemblies(row.bom_no, company, qty, secondary_items)
return scrap_items
return secondary_items

View File

@@ -895,7 +895,7 @@ def create_bom_with_process_loss_item(
if scrap_qty:
bom_doc.append(
"scrap_items",
"secondary_items",
{
"item_code": fg_item.item_code,
"qty": scrap_qty,

View File

@@ -36,15 +36,17 @@
"quantity": 1.0
},
{
"scrap_items":[
"secondary_items":[
{
"amount": 2000.0,
"doctype": "BOM Scrap Item",
"doctype": "BOM Secondary Item",
"item_code": "_Test Item Home Desktop 100",
"parentfield": "scrap_items",
"parentfield": "secondary_items",
"stock_qty": 1.0,
"rate": 2000.0,
"stock_uom": "_Test UOM"
"stock_uom": "_Test UOM",
"type": "Scrap",
"is_legacy": 1
}
],
"items": [

View File

@@ -356,7 +356,6 @@ class BOMCreator(Document):
{
"bom_no": bom_no,
"allow_alternative_item": 1,
"allow_scrap_items": not item.get("is_phantom_item"),
"include_item_in_manufacturing": 1,
}
)

View File

@@ -1,109 +0,0 @@
{
"actions": [],
"creation": "2016-09-26 02:19:21.642081",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"item_code",
"column_break_2",
"item_name",
"quantity_and_rate",
"stock_qty",
"rate",
"amount",
"column_break_6",
"stock_uom",
"base_rate",
"base_amount"
],
"fields": [
{
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
"options": "Item",
"reqd": 1
},
{
"fieldname": "item_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Item Name"
},
{
"fieldname": "quantity_and_rate",
"fieldtype": "Section Break",
"label": "Quantity and Rate"
},
{
"fieldname": "stock_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Qty",
"non_negative": 1,
"reqd": 1
},
{
"fieldname": "rate",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Rate",
"non_negative": 1,
"options": "currency"
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"label": "Amount",
"options": "currency",
"read_only": 1
},
{
"fieldname": "column_break_6",
"fieldtype": "Column Break"
},
{
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
"options": "UOM",
"read_only": 1
},
{
"fieldname": "base_rate",
"fieldtype": "Currency",
"label": "Basic Rate (Company Currency)",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "base_amount",
"fieldtype": "Currency",
"label": "Basic Amount (Company Currency)",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
}
],
"istable": 1,
"links": [],
"modified": "2025-07-31 16:21:44.047007",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Scrap Item",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -0,0 +1,232 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2026-02-25 12:44:21.760154",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"type",
"rate",
"column_break_gres",
"is_legacy",
"section_break_sbnk",
"item_code",
"item_name",
"uom",
"column_break_atlf",
"qty",
"stock_uom",
"conversion_factor",
"stock_qty",
"section_break_yith",
"image",
"description",
"column_break_wsra",
"image_nygv",
"section_break_ielf",
"cost_allocation_per",
"process_loss_per",
"column_break_gtbl",
"cost",
"base_cost",
"process_loss_qty"
],
"fields": [
{
"depends_on": "eval:!doc.is_legacy",
"fieldname": "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"
},
{
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
"options": "Item",
"reqd": 1
},
{
"fetch_from": "item_code.item_name",
"fieldname": "item_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Item Name",
"read_only": 1
},
{
"default": "0",
"fieldname": "cost",
"fieldtype": "Currency",
"label": "Cost",
"no_copy": 1,
"non_negative": 1,
"options": "currency",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "section_break_sbnk",
"fieldtype": "Section Break"
},
{
"fetch_from": "item_code.stock_uom",
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
"options": "UOM",
"read_only": 1
},
{
"fieldname": "column_break_atlf",
"fieldtype": "Column Break"
},
{
"fieldname": "uom",
"fieldtype": "Link",
"in_list_view": 1,
"label": "UOM",
"options": "UOM",
"reqd": 1
},
{
"default": "1",
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor",
"non_negative": 1,
"reqd": 1
},
{
"depends_on": "eval:!doc.is_legacy",
"fieldname": "section_break_ielf",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_gtbl",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_yith",
"fieldtype": "Section Break"
},
{
"fetch_from": "item_code.image",
"fieldname": "image",
"fieldtype": "Attach Image",
"hidden": 1,
"label": "Image",
"read_only": 1
},
{
"fieldname": "column_break_wsra",
"fieldtype": "Column Break"
},
{
"fieldname": "stock_qty",
"fieldtype": "Float",
"label": "Stock Qty",
"non_negative": 1,
"read_only": 1
},
{
"fieldname": "qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Qty",
"non_negative": 1,
"reqd": 1
},
{
"default": "0",
"fieldname": "cost_allocation_per",
"fieldtype": "Percent",
"label": "Cost Allocation %",
"non_negative": 1,
"reqd": 1
},
{
"default": "0",
"fieldname": "process_loss_per",
"fieldtype": "Percent",
"label": "Process Loss %",
"non_negative": 1,
"reqd": 1
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
},
{
"depends_on": "image",
"fieldname": "image_nygv",
"fieldtype": "Image",
"options": "image",
"read_only": 1
},
{
"default": "0",
"fieldname": "base_cost",
"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": "0",
"fieldname": "process_loss_qty",
"fieldtype": "Float",
"label": "Process Loss Qty",
"no_copy": 1,
"non_negative": 1,
"read_only": 1,
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-03-11 12:12:29.208031",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Secondary Item",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -1,11 +1,11 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class BOMScrapItem(Document):
class BOMSecondaryItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -14,17 +14,26 @@ class BOMScrapItem(Document):
if TYPE_CHECKING:
from frappe.types import DF
amount: DF.Currency
base_amount: DF.Currency
base_rate: DF.Currency
base_cost: DF.Currency
conversion_factor: DF.Float
cost: DF.Currency
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
parentfield: DF.Data
parenttype: DF.Data
process_loss_per: DF.Percent
process_loss_qty: DF.Float
qty: DF.Float
rate: DF.Currency
stock_qty: DF.Float
stock_uom: DF.Link | None
type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
uom: DF.Link
# end: auto-generated types
pass

View File

@@ -23,7 +23,7 @@ frappe.ui.form.on("Job Card", {
};
});
frm.set_query("item_code", "scrap_items", () => {
frm.set_query("item_code", "secondary_items", () => {
return {
filters: {
disabled: 0,
@@ -104,7 +104,7 @@ frappe.ui.form.on("Job Card", {
frm.doc.docstatus === 1 &&
!frm.doc.is_subcontracted &&
(frm.doc.skip_material_transfer || frm.doc.transferred_qty > 0) &&
flt(frm.doc.for_quantity) + flt(frm.doc.process_loss_qty) > flt(frm.doc.manufactured_qty)
flt(frm.doc.manufactured_qty) + flt(frm.doc.process_loss_qty) < flt(frm.doc.for_quantity)
) {
frm.add_custom_button(__("Make Stock Entry"), () => {
frappe.confirm(
@@ -278,8 +278,6 @@ frappe.ui.form.on("Job Card", {
frm.trigger("complete_job_card");
});
}
frm.trigger("make_dashboard");
}
}

View File

@@ -59,8 +59,8 @@
"time_logs",
"section_break_21",
"sub_operations",
"scrap_items_section",
"scrap_items",
"secondary_items_section",
"secondary_items",
"corrective_operation_section",
"for_job_card",
"is_corrective_job_card",
@@ -406,20 +406,6 @@
"options": "Batch",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "scrap_items_section",
"fieldtype": "Tab Break",
"label": "Scrap Items"
},
{
"fieldname": "scrap_items",
"fieldtype": "Table",
"label": "Scrap Items",
"no_copy": 1,
"options": "Job Card Scrap Item",
"print_hide": 1
},
{
"fetch_from": "operation.quality_inspection_template",
"fieldname": "quality_inspection_template",
@@ -623,12 +609,26 @@
{
"fieldname": "column_break_xhzg",
"fieldtype": "Column Break"
},
{
"fieldname": "secondary_items",
"fieldtype": "Table",
"label": "Secondary Items",
"no_copy": 1,
"options": "Job Card Secondary Item",
"print_hide": 1
},
{
"collapsible": 1,
"fieldname": "secondary_items_section",
"fieldtype": "Tab Break",
"label": "Secondary Items"
}
],
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2026-02-06 18:27:03.178783",
"modified": "2026-02-26 15:13:56.767070",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",

View File

@@ -71,7 +71,9 @@ class JobCard(Document):
from erpnext.manufacturing.doctype.job_card_scheduled_time.job_card_scheduled_time import (
JobCardScheduledTime,
)
from erpnext.manufacturing.doctype.job_card_scrap_item.job_card_scrap_item import JobCardScrapItem
from erpnext.manufacturing.doctype.job_card_secondary_item.job_card_secondary_item import (
JobCardSecondaryItem,
)
from erpnext.manufacturing.doctype.job_card_time_log.job_card_time_log import JobCardTimeLog
actual_end_date: DF.Datetime | None
@@ -110,7 +112,7 @@ class JobCard(Document):
remarks: DF.SmallText | None
requested_qty: DF.Float
scheduled_time_logs: DF.Table[JobCardScheduledTime]
scrap_items: DF.Table[JobCardScrapItem]
secondary_items: DF.Table[JobCardSecondaryItem]
semi_fg_bom: DF.Link | None
sequence_id: DF.Int
serial_and_batch_bundle: DF.Link | None
@@ -199,6 +201,7 @@ class JobCard(Document):
def set_manufactured_qty(self):
table_name = "Stock Entry"
child_name = "Stock Entry Detail"
if self.is_subcontracted:
table_name = "Subcontracting Receipt Item"
@@ -208,8 +211,13 @@ class JobCard(Document):
if self.is_subcontracted:
query = query.select(Sum(table.qty))
else:
query = query.select(Sum(table.fg_completed_qty))
query = query.where(table.purpose == "Manufacture")
child = frappe.qb.DocType(child_name)
query = (
query.join(child)
.on(table.name == child.parent)
.select(Sum(child.transfer_qty))
.where((table.purpose == "Manufacture") & (child.is_finished_item == 1))
)
qty = query.run()[0][0] or 0.0
self.manufactured_qty = flt(qty)
@@ -267,25 +275,35 @@ class JobCard(Document):
row.sub_operation = row.operation
self.append("sub_operations", row)
def set_scrap_items(self):
if not self.semi_fg_bom:
def set_secondary_items(self):
if not self.semi_fg_bom and not self.bom_no:
return
items_dict = get_bom_items_as_dict(
self.semi_fg_bom, self.company, qty=self.for_quantity, fetch_exploded=0, fetch_scrap_items=1
self.semi_fg_bom or self.bom_no,
self.company,
qty=self.for_quantity,
fetch_exploded=0,
fetch_secondary_items=1,
)
for item_code, values in items_dict.items():
values = frappe._dict(values)
secondary_item = {
"item_code": item_code,
"stock_qty": values.qty,
"item_name": values.item_name,
"stock_uom": values.stock_uom,
"type": values.type,
"bom_secondary_item": values.name,
}
self.append(
"scrap_items",
{
"item_code": item_code,
"stock_qty": values.qty,
"item_name": values.item_name,
"stock_uom": values.stock_uom,
},
)
if not values.is_legacy:
secondary_item["stock_qty"] -= flt(
secondary_item["stock_qty"] * (values.process_loss_per / 100),
self.precision("for_quantity"),
)
self.append("secondary_items", secondary_item)
def validate_time_logs(self, save=False):
self.total_time_in_mins = 0.0
@@ -1181,7 +1199,7 @@ class JobCard(Document):
def set_status(self, update_status=False):
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
if self.finished_good and self.docstatus == 1:
if self.manufactured_qty >= self.for_quantity:
if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity:
self.status = "Completed"
elif self.transferred_qty > 0 or self.skip_material_transfer:
self.status = "Work In Progress"
@@ -1456,12 +1474,24 @@ class JobCard(Document):
)
@frappe.whitelist()
def make_stock_entry_for_semi_fg_item(self, auto_submit=False):
def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False):
def get_consumed_process_loss():
table = frappe.qb.DocType("Stock Entry")
query = (
frappe.qb.from_(table)
.select(Sum(table.process_loss_qty))
.where(
(table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1)
)
)
return query.run()[0][0] or 0
from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry
ste = ManufactureEntry(
{
"for_quantity": self.for_quantity - self.manufactured_qty,
"process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0),
"job_card": self.name,
"skip_material_transfer": self.skip_material_transfer,
"backflush_from_wip_warehouse": self.backflush_from_wip_warehouse,
@@ -1481,9 +1511,10 @@ class JobCard(Document):
wo_doc = frappe.get_doc("Work Order", self.work_order)
add_additional_cost(ste.stock_entry, wo_doc, self)
ste.stock_entry.set_scrap_items()
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.is_scrap_item and not row.t_warehouse:
if (row.type or row.is_legacy_scrap_item) and not row.t_warehouse:
row.t_warehouse = self.target_warehouse
if auto_submit:

View File

@@ -882,6 +882,193 @@ class TestJobCard(ERPNextTestSuite):
s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6))
self.assertEqual(s.additional_costs[0].amount, 8)
def test_co_by_product_for_sfg_flow(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0)
def create_bom(raw_material, finished_good, scrap_item, submit=True):
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = finished_good
bom.quantity = 1
bom.append("items", {"item_code": raw_material, "qty": 1})
bom.append(
"secondary_items",
{
"item_code": scrap_item,
"qty": 1,
"process_loss_per": 10,
"cost_allocation_per": 5,
"type": "Scrap",
},
)
if submit:
bom.insert()
bom.submit()
return bom
rm1 = create_item("RM 1")
scrap1 = create_item("Scrap 1")
sfg = create_item("SFG 1")
sfg_bom = create_bom(rm1.name, sfg.name, scrap1.name)
rm2 = create_item("RM 2")
fg1 = create_item("FG 1")
scrap2 = create_item("Scrap 2")
scrap_extra = create_item("Scrap Extra")
fg_bom = create_bom(rm2.name, fg1.name, scrap2.name, submit=False)
fg_bom.with_operations = 1
fg_bom.track_semi_finished_goods = 1
operation1 = {
"operation": "Test Operation A",
"workstation": "_Test Workstation A",
"finished_good": sfg.name,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 30,
}
operation2 = {
"operation": "Test Operation B",
"workstation": "_Test Workstation A",
"finished_good": fg1.name,
"bom_no": fg_bom.name,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 30,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": sfg.name, "qty": 1, "uom": "Nos", "operation_row_id": 2})
fg_bom.insert()
fg_bom.save()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1.name,
qty=10,
source_warehouse="Stores - _TC",
fg_warehouse="Finished Goods - _TC",
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.operations[1].time_in_mins = 60
work_order.save()
work_order.submit()
job_card = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "Test Operation A"}, "name"
),
)
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.append(
"secondary_items", {"item_code": scrap_extra.name, "stock_qty": 5, "type": "Co-Product"}
)
job_card.submit()
for row in sfg_bom.items:
make_stock_entry(
item_code=row.item_code,
target="Stores - _TC",
qty=10,
basic_rate=100,
)
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
manufacturing_entry.submit()
self.assertEqual(manufacturing_entry.items[2].item_code, scrap1.name)
self.assertEqual(manufacturing_entry.items[2].qty, 9)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556)
self.assertEqual(manufacturing_entry.items[3].item_code, scrap_extra.name)
self.assertEqual(manufacturing_entry.items[3].type, "Co-Product")
self.assertEqual(manufacturing_entry.items[3].qty, 5)
self.assertEqual(manufacturing_entry.items[3].basic_rate, 0)
job_card = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "Test Operation B"}, "name"
),
)
job_card.append(
"time_logs",
{
"from_time": "2009-02-01 12:06:25",
"to_time": "2009-02-01 12:37:25",
"completed_qty": job_card.for_quantity,
},
)
job_card.submit()
for row in fg_bom.items:
make_stock_entry(
item_code=row.item_code,
target="Stores - _TC",
qty=10,
basic_rate=100,
)
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
manufacturing_entry.submit()
self.assertEqual(manufacturing_entry.items[2].item_code, scrap2.name)
self.assertEqual(manufacturing_entry.items[2].qty, 9)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556)
def test_secondary_items_without_sfg(self):
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": "_Test Item", "stock_qty": 2, "type": "Scrap"})
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"))
s.submit()
self.assertEqual(s.items[3].item_code, "_Test Item")
self.assertEqual(s.items[3].transfer_qty, 2)
def create_bom_with_multiple_operations():
"Create a BOM with multiple operations and Material Transfer against Job Card"

View File

@@ -5,10 +5,12 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"type",
"description",
"column_break_3",
"item_code",
"item_name",
"column_break_3",
"description",
"bom_secondary_item",
"quantity_and_rate",
"stock_qty",
"column_break_6",
@@ -19,7 +21,7 @@
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Scrap Item Code",
"label": "Secondary Item Code",
"options": "Item",
"reqd": 1
},
@@ -28,7 +30,7 @@
"fieldname": "item_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Scrap Item Name"
"label": "Secondary Item Name"
},
{
"fieldname": "column_break_3",
@@ -65,20 +67,36 @@
"label": "Stock UOM",
"options": "UOM",
"read_only": 1
},
{
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Co-Product\nBy-Product\nScrap\nAdditional Finished Good",
"reqd": 1
},
{
"fieldname": "bom_secondary_item",
"fieldtype": "Data",
"hidden": 1,
"label": "BOM Secondary Item Reference",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-07-29 13:09:57.323835",
"modified": "2026-03-06 13:51:00.492621",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card Scrap Item",
"name": "Job Card Secondary Item",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -4,7 +4,7 @@
from frappe.model.document import Document
class JobCardScrapItem(Document):
class JobCardSecondaryItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -13,6 +13,7 @@ class JobCardScrapItem(Document):
if TYPE_CHECKING:
from frappe.types import DF
bom_secondary_item: DF.Data | None
description: DF.SmallText | None
item_code: DF.Link
item_name: DF.Data | None
@@ -21,6 +22,7 @@ class JobCardScrapItem(Document):
parenttype: DF.Data
stock_qty: DF.Float
stock_uom: DF.Link | None
type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
# end: auto-generated types
pass

View File

@@ -36,7 +36,7 @@
"capacity_planning_for_days",
"mins_between_operations",
"other_settings_section",
"set_op_cost_and_scrap_from_sub_assemblies",
"set_op_cost_and_secondary_items_from_sub_assemblies",
"column_break_23",
"make_serial_no_batch_from_work_order"
],
@@ -202,13 +202,6 @@
"fieldtype": "Check",
"label": "Validate Components and Quantities Per BOM"
},
{
"default": "0",
"description": "To include sub-assembly costs and scrap items in Finished Goods on a work order without using a job card, when the 'Use Multi-Level BOM' option is enabled.",
"fieldname": "set_op_cost_and_scrap_from_sub_assemblies",
"fieldtype": "Check",
"label": "Set Operating Cost / Scrap Items From Sub-assemblies"
},
{
"default": "0",
"description": "Enabling this checkbox will force each Job Card Time Log to have From Time and To Time",
@@ -237,6 +230,13 @@
"fieldname": "allow_editing_of_items_and_quantities_in_work_order",
"fieldtype": "Check",
"label": "Allow Editing of Items and Quantities in Work Order"
},
{
"default": "0",
"description": "To include sub-assembly costs and secondary items in Finished Goods on a work order without using a job card, when the 'Use Multi-Level BOM' option is enabled.",
"fieldname": "set_op_cost_and_secondary_items_from_sub_assemblies",
"fieldtype": "Check",
"label": "Set Operating Cost / Secondary Items From Sub-assemblies"
}
],
"hide_toolbar": 0,
@@ -244,7 +244,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-16 13:28:20.714576",
"modified": "2026-03-20 13:28:20.714576",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing Settings",

View File

@@ -32,7 +32,7 @@ class ManufacturingSettings(Document):
mins_between_operations: DF.Int
overproduction_percentage_for_sales_order: DF.Percent
overproduction_percentage_for_work_order: DF.Percent
set_op_cost_and_scrap_from_sub_assemblies: DF.Check
set_op_cost_and_secondary_items_from_sub_assemblies: DF.Check
transfer_extra_materials_percentage: DF.Percent
update_bom_costs_automatically: DF.Check
validate_components_quantities_per_bom: DF.Check

View File

@@ -2875,6 +2875,7 @@ def make_bom(**args):
"company": args.company or "_Test Company",
"routing": args.routing,
"with_operations": args.with_operations or 0,
"process_loss_percentage": args.process_loss_percentage or 0,
}
)
@@ -2896,6 +2897,23 @@ def make_bom(**args):
},
)
if args.scrap_items:
for item in args.scrap_items:
item_doc = frappe.get_doc("Item", item)
bom.append(
"secondary_items",
{
"type": "Scrap",
"item_code": item,
"item_name": item,
"uom": item_doc.stock_uom,
"stock_uom": item_doc.stock_uom,
"qty": args.scrap_qty or 1,
"cost_allocation_per": args.scrap_cost_allocation_per or 10,
"process_loss_per": args.scrap_process_loss_per or 10,
},
)
if not args.do_not_save:
bom.insert(ignore_permissions=True)

View File

@@ -329,7 +329,7 @@ class TestWorkOrder(ERPNextTestSuite):
cint(bin1_on_stop_production.projected_qty) + 1, cint(self.bin1_at_start.projected_qty)
)
def test_scrap_material_qty(self):
def test_secondary_material_qty(self):
wo_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
# add raw materials to stores
@@ -354,15 +354,15 @@ class TestWorkOrder(ERPNextTestSuite):
"Work Order", wo_order.name, ["scrap_warehouse", "qty", "produced_qty", "bom_no"], as_dict=1
)
scrap_item_details = get_scrap_item_details(wo_order_details.bom_no)
secondary_item_details = get_secondary_item_details(wo_order_details.bom_no)
self.assertEqual(wo_order_details.produced_qty, 2)
for item in s.items:
if item.bom_no and item.item_code in scrap_item_details:
if item.bom_no and item.item_code in secondary_item_details:
self.assertEqual(wo_order_details.scrap_warehouse, item.t_warehouse)
self.assertEqual(
flt(wo_order_details.qty) * flt(scrap_item_details[item.item_code]), item.qty
flt(wo_order_details.qty) * flt(secondary_item_details[item.item_code]), item.qty
)
def test_allow_overproduction(self):
@@ -1015,7 +1015,7 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(wo.status, "Completed")
@timeout(seconds=60)
def test_job_card_scrap_item(self):
def test_job_card_secondary_item(self):
items = [
"Test FG Item for Scrap Item Test",
"Test RM Item 1 for Scrap Item Test",
@@ -1074,7 +1074,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.is_scrap_item:
if row.type or row.is_legacy_scrap_item:
self.assertEqual(row.qty, 1)
# Partial Job Card 1 with qty 10
@@ -1086,7 +1086,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.is_scrap_item:
if row.type or row.is_legacy_scrap_item:
self.assertEqual(row.qty, 2)
# Partial Job Card 2 with qty 10
@@ -2134,10 +2134,12 @@ class TestWorkOrder(ERPNextTestSuite):
for row in se_doc.additional_costs:
self.assertEqual(row.expense_account, operating_cost_account)
def test_op_cost_and_scrap_based_on_sub_assemblies(self):
def test_set_op_cost_and_secondary_items_from_sub_assemblies(self):
# Make Sub Assembly BOM 1
frappe.db.set_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies", 1)
frappe.db.set_single_value(
"Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies", 1
)
items = {
"Test Final FG Item": 0,
@@ -2169,16 +2171,20 @@ class TestWorkOrder(ERPNextTestSuite):
se_doc.save()
self.assertTrue(se_doc.additional_costs)
scrap_items = []
secondary_items = []
for item in se_doc.items:
if item.is_scrap_item:
scrap_items.append(item.item_code)
if item.type or item.is_legacy_scrap_item:
secondary_items.append(item.item_code)
self.assertEqual(sorted(scrap_items), sorted(["Test Final Scrap Item 1", "Test Final Scrap Item 2"]))
self.assertEqual(
sorted(secondary_items), sorted(["Test Final Scrap Item 1", "Test Final Scrap Item 2"])
)
for row in se_doc.additional_costs:
self.assertEqual(row.amount, 3000)
frappe.db.set_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies", 0)
frappe.db.set_single_value(
"Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies", 0
)
@ERPNextTestSuite.change_settings(
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1}
@@ -3951,7 +3957,7 @@ def prepare_boms_for_sub_assembly_test():
do_not_submit=True,
)
bom.append("scrap_items", {"item_code": "Test Final Scrap Item 1", "qty": 1})
bom.append("secondary_items", {"item_code": "Test Final Scrap Item 1", "qty": 1, "is_legacy": 1})
bom.submit()
@@ -3964,7 +3970,7 @@ def prepare_boms_for_sub_assembly_test():
do_not_submit=True,
)
bom.append("scrap_items", {"item_code": "Test Final Scrap Item 2", "qty": 1})
bom.append("secondary_items", {"item_code": "Test Final Scrap Item 2", "qty": 1, "is_legacy": 1})
bom.submit()
@@ -4159,7 +4165,7 @@ def update_job_card(job_card, jc_qty=None, days=None):
employee = frappe.db.get_value("Employee", {"status": "Active"}, "name")
job_card_doc = frappe.get_doc("Job Card", job_card)
job_card_doc.set(
"scrap_items",
"secondary_items",
[
{"item_code": "Test RM Item 1 for Scrap Item Test", "stock_qty": 2},
{"item_code": "Test RM Item 2 for Scrap Item Test", "stock_qty": 2},
@@ -4199,17 +4205,17 @@ def update_job_card(job_card, jc_qty=None, days=None):
job_card_doc.submit()
def get_scrap_item_details(bom_no):
scrap_items = {}
def get_secondary_item_details(bom_no):
secondary_items = {}
for item in frappe.db.sql(
"""select item_code, stock_qty from `tabBOM Scrap Item`
"""select item_code, stock_qty from `tabBOM Secondary Item`
where parent = %s""",
bom_no,
as_dict=1,
):
scrap_items[item.item_code] = item.stock_qty
secondary_items[item.item_code] = item.stock_qty
return scrap_items
return secondary_items
def allow_overproduction(fieldname, percentage):

View File

@@ -387,6 +387,7 @@ frappe.ui.form.on("Work Order", {
args: {
work_order: frm.doc.name,
operations: selected_rows,
parent_bom: frm.doc.bom_no,
},
callback: function () {
frm.reload_doc();

View File

@@ -2356,7 +2356,7 @@ def check_if_scrap_warehouse_mandatory(bom_no):
if bom_no:
bom = frappe.get_doc("BOM", bom_no)
if len(bom.scrap_items) > 0:
if bom.has_scrap_items():
res["set_scrap_wh_mandatory"] = True
return res
@@ -2420,6 +2420,7 @@ def make_stock_entry(
stock_entry.set_stock_entry_type()
stock_entry.is_additional_transfer_entry = is_additional_transfer_entry
stock_entry.get_items()
stock_entry.set_secondary_items_from_job_card()
if purpose != "Disassemble":
stock_entry.set_serial_no_batch_for_finished_good()
@@ -2478,14 +2479,14 @@ def query_sales_order(doctype, txt, searchfield, start, page_len, filters) -> li
@frappe.whitelist()
def make_job_card(work_order, operations):
def make_job_card(work_order: str, operations: str | list, parent_bom: str | None = None):
if isinstance(operations, str):
operations = json.loads(operations)
work_order = frappe.get_doc("Work Order", work_order)
for row in operations:
row = frappe._dict(row)
row.update(get_operation_details(row.name, work_order))
row.update(get_operation_details(row.name, work_order, parent_bom))
validate_operation_data(row)
qty = row.get("qty")
@@ -2495,7 +2496,7 @@ def make_job_card(work_order, operations):
create_job_card(work_order, row, auto_create=True)
def get_operation_details(name, work_order):
def get_operation_details(name, work_order, parent_bom):
for row in work_order.operations:
if row.name == name:
return {
@@ -2505,7 +2506,7 @@ def get_operation_details(name, work_order):
"fg_warehouse": row.fg_warehouse,
"wip_warehouse": row.wip_warehouse,
"finished_good": row.finished_good,
"bom_no": row.get("bom_no"),
"bom_no": row.get("bom_no") or parent_bom,
"is_subcontracted": row.get("is_subcontracted"),
}
@@ -2640,8 +2641,9 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create
work_order.transfer_material_against == "Job Card" and not work_order.skip_transfer
):
doc.get_required_items()
if work_order.track_semi_finished_goods:
doc.set_scrap_items()
if work_order.track_semi_finished_goods:
doc.set_secondary_items()
if auto_create:
doc.flags.ignore_mandatory = True

View File

@@ -472,3 +472,4 @@ erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po
erpnext.patches.v16_0.enable_serial_batch_setting
erpnext.patches.v16_0.update_requested_qty_packed_item
erpnext.patches.v16_0.remove_payables_receivables_workspace
erpnext.patches.v16_0.co_by_product_patch

View File

@@ -0,0 +1,104 @@
from collections import defaultdict
import frappe
from frappe.model.utils.rename_field import rename_field
def execute():
copy_doctypes()
rename_fields()
def copy_doctypes():
previous = frappe.db.auto_commit_on_many_writes
frappe.db.auto_commit_on_many_writes = True
try:
insert_into_bom()
insert_into_job_card()
if frappe.db.has_table("Subcontracting Inward Order Scrap Item"):
insert_into_subcontracting_inward()
finally:
frappe.db.auto_commit_on_many_writes = previous
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])
grouped_data = defaultdict(list)
for item in data:
grouped_data[item.parent].append(item)
for parent, items in grouped_data.items():
bom = frappe.get_doc("BOM", parent)
for item in items:
secondary_item = frappe.new_doc(
"BOM Secondary Item", parent_doc=bom, parentfield="secondary_items"
)
secondary_item.update({field: item[field] for field in fields})
secondary_item.update(
{
"uom": item.stock_uom,
"conversion_factor": 1,
"qty": item.stock_qty,
"is_legacy": 1,
"type": "Scrap",
}
)
secondary_item.insert()
def insert_into_job_card():
fields = ["item_code", "item_name", "description", "stock_qty", "stock_uom"]
bulk_insert("Job Card", "Job Card Scrap Item", "Job Card Secondary Item", fields, ["type"], ["Scrap"])
def insert_into_subcontracting_inward():
fields = [
"item_code",
"fg_item_code",
"stock_uom",
"warehouse",
"reference_name",
"produced_qty",
"delivered_qty",
]
bulk_insert(
"Subcontracting Inward Order",
"Subcontracting Inward Order Scrap Item",
"Subcontracting Inward Order Secondary Item",
fields,
["type"],
["Scrap"],
)
def bulk_insert(parent_doctype, old_doctype, new_doctype, old_fields, new_fields, new_values):
data = frappe.get_all(old_doctype, {"docstatus": ("<", 2)}, ["parent", *old_fields])
grouped_data = defaultdict(list)
for item in data:
grouped_data[item.parent].append(item)
for parent, items in grouped_data.items():
parent_doc = frappe.get_doc(parent_doctype, parent)
for item in items:
secondary_item = frappe.new_doc(new_doctype, parent_doc=parent_doc, parentfield="secondary_items")
secondary_item.update({old_field: item[old_field] for old_field in old_fields})
secondary_item.update(
{new_field: new_value for new_field, new_value in zip(new_fields, new_values, strict=True)}
)
secondary_item.insert()
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")
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")
rename_field("Subcontracting Receipt Item", "scrap_cost_per_qty", "secondary_items_cost_per_qty")

View File

@@ -1855,7 +1855,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
"base_operating_cost",
"base_raw_material_cost",
"base_total_cost",
"base_scrap_material_cost",
"base_secondary_items_cost",
"base_totals_section",
],
company_currency
@@ -1873,7 +1873,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
"paid_amount",
"write_off_amount",
"operating_cost",
"scrap_material_cost",
"secondary_items_cost",
"raw_material_cost",
"total_cost",
"totals_section",
@@ -1919,7 +1919,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
"base_operating_cost",
"base_raw_material_cost",
"base_total_cost",
"base_scrap_material_cost",
"base_secondary_items_cost",
"base_rounding_adjustment",
],
this.frm.doc.currency != company_currency
@@ -1984,11 +1984,11 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
});
}
if (this.frm.doc.scrap_items && this.frm.doc.scrap_items.length > 0) {
this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "scrap_items");
this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "scrap_items");
if (this.frm.doc.secondary_items && this.frm.doc.secondary_items.length > 0) {
this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "secondary_items");
this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "secondary_items");
var item_grid = this.frm.fields_dict["scrap_items"].grid;
var item_grid = this.frm.fields_dict["secondary_items"].grid;
$.each(["base_rate", "base_amount"], function (i, fname) {
if (frappe.meta.get_docfield(item_grid.doctype, fname))
item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency);

View File

@@ -49,7 +49,7 @@
"section_break_zwh6",
"allow_delivery_of_overproduced_qty",
"column_break_mla9",
"deliver_scrap_items"
"deliver_secondary_items"
],
"fields": [
{
@@ -260,13 +260,6 @@
"fieldname": "column_break_mla9",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "If enabled, the Scrap Item generated against a Finished Good will also be added in the Stock Entry when delivering that Finished Good.",
"fieldname": "deliver_scrap_items",
"fieldtype": "Check",
"label": "Deliver Scrap Items"
},
{
"fieldname": "item_price_tab",
"fieldtype": "Tab Break",
@@ -320,6 +313,13 @@
"fieldname": "enable_utm",
"fieldtype": "Check",
"label": "Enable UTM"
},
{
"default": "0",
"description": "If enabled, the Secondary Items generated against a Finished Good will also be added in the Stock Entry when delivering that Finished Good.",
"fieldname": "deliver_secondary_items",
"fieldtype": "Check",
"label": "Deliver Secondary Items"
}
],
"grid_page_length": 50,

View File

@@ -41,7 +41,7 @@ class SellingSettings(Document):
blanket_order_allowance: DF.Float
cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"]
customer_group: DF.Link | None
deliver_scrap_items: DF.Check
deliver_secondary_items: DF.Check
dn_required: DF.Literal["No", "Yes"]
dont_reserve_sales_order_qty_on_sales_return: DF.Check
editable_bundle_item_rates: DF.Check

View File

@@ -820,7 +820,7 @@ class Company(NestedSet):
boms = frappe.db.sql_list("select name from tabBOM where company=%s", self.name)
if boms:
frappe.db.sql("delete from tabBOM where company=%s", self.name)
for dt in ("BOM Operation", "BOM Item", "BOM Scrap Item", "BOM Explosion Item"):
for dt in ("BOM Operation", "BOM Item", "BOM Secondary Item", "BOM Explosion Item"):
frappe.db.sql(
"delete from `tab{}` where parent in ({})".format(dt, ", ".join(["%s"] * len(boms))),
tuple(boms),

View File

@@ -1334,13 +1334,13 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
}
fg_completed_qty() {
this.get_items();
if (!this.frm.doc.job_card) {
this.get_items();
}
}
get_items() {
var me = this;
if (!this.frm.doc.fg_completed_qty || !this.frm.doc.bom_no)
frappe.throw(__("BOM and Manufacturing Quantity are required"));
if (this.frm.doc.work_order || this.frm.doc.bom_no) {
// if work order / bom is mentioned, get items

View File

@@ -31,7 +31,7 @@ from erpnext.manufacturing.doctype.bom.bom import (
add_additional_cost,
get_bom_items_as_dict,
get_op_cost_from_sub_assemblies,
get_scrap_items_from_sub_assemblies,
get_secondary_items_from_sub_assemblies,
validate_bom_no,
)
from erpnext.setup.doctype.brand.brand import get_brand_defaults
@@ -245,7 +245,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.validate_company_in_accounting_dimension()
if self.purpose in ("Manufacture", "Repack"):
self.mark_finished_and_scrap_items()
self.mark_finished_and_secondary_items()
if not self.job_card:
self.validate_finished_goods()
else:
@@ -272,7 +272,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.validate_component_and_quantities()
if self.get("purpose") != "Manufacture":
# ignore scrap item wh difference and empty source/target wh
# ignore other item wh difference and empty source/target wh
# in Manufacture Entry
self.reset_default_field_value("from_warehouse", "items", "s_warehouse")
self.reset_default_field_value("to_warehouse", "items", "t_warehouse")
@@ -656,7 +656,7 @@ class StockEntry(StockController, SubcontractingInwardController):
item.expense_account = frappe.get_value("Company", self.company, "default_expense_account")
def validate_fg_completed_qty(self):
if self.purpose != "Manufacture":
if self.purpose != "Manufacture" or not self.from_bom:
return
fg_qty = defaultdict(float)
@@ -789,7 +789,7 @@ class StockEntry(StockController, SubcontractingInwardController):
if self.purpose == "Manufacture":
if has_bom:
if d.is_finished_item or d.is_scrap_item:
if d.is_finished_item or d.type or d.is_legacy_scrap_item:
d.s_warehouse = None
if not d.t_warehouse:
frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx))
@@ -1093,11 +1093,10 @@ class StockEntry(StockController, SubcontractingInwardController):
def set_basic_rate(self, reset_outgoing_rate=True, raise_error_if_no_rate=True):
"""
Set rate for outgoing, scrapped and finished items
Set rate for outgoing, secondary and finished items
"""
# Set rate for outgoing items
outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate)
finished_item_qty = sum(d.transfer_qty for d in self.items if d.is_finished_item)
items = []
# Set basic rate for incoming items
@@ -1111,11 +1110,19 @@ class StockEntry(StockController, SubcontractingInwardController):
elif d.is_finished_item:
if self.purpose == "Manufacture":
d.basic_rate = self.get_basic_rate_for_manufactured_item(
finished_item_qty, outgoing_items_cost
d.transfer_qty, outgoing_items_cost
)
elif self.purpose == "Repack":
d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost)
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 = frappe.get_value(
"BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per"
)
d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty
if not d.basic_rate and not d.allow_zero_valuation_rate:
if self.is_new():
raise_error_if_no_rate = False
@@ -1198,7 +1205,7 @@ class StockEntry(StockController, SubcontractingInwardController):
def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float:
settings = frappe.get_single("Manufacturing Settings")
scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_scrap_item])
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:
@@ -1212,7 +1219,7 @@ class StockEntry(StockController, SubcontractingInwardController):
},
):
for item in self.items:
if not item.is_finished_item and not item.is_scrap_item:
if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item:
label = frappe.get_meta(settings.doctype).get_label(
"get_rm_cost_from_consumption_entry"
)
@@ -1614,7 +1621,7 @@ class StockEntry(StockController, SubcontractingInwardController):
order,
)
def mark_finished_and_scrap_items(self):
def mark_finished_and_secondary_items(self):
if self.purpose != "Repack" and any(
[d.item_code for d in self.items if (d.is_finished_item and d.t_warehouse)]
):
@@ -1631,11 +1638,9 @@ class StockEntry(StockController, SubcontractingInwardController):
if d.t_warehouse and not d.s_warehouse:
if self.purpose == "Repack" or d.item_code == finished_item:
d.is_finished_item = 1
else:
d.is_scrap_item = 1
else:
d.is_finished_item = 0
d.is_scrap_item = 0
d.type = ""
def get_finished_item(self):
finished_item = None
@@ -2434,7 +2439,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.load_items_from_bom()
self.set_serial_batch_from_reserved_entry()
self.set_scrap_items()
self.set_secondary_items()
self.set_actual_qty()
self.validate_customer_provided_item()
self.calculate_rate_and_amount(raise_error_if_no_rate=False)
@@ -2579,14 +2584,21 @@ class StockEntry(StockController, SubcontractingInwardController):
return query.run(as_dict=True)
def set_scrap_items(self):
if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]:
scrap_item_dict = self.get_bom_scrap_material(self.fg_completed_qty)
for item in scrap_item_dict.values():
if self.pro_doc and self.pro_doc.scrap_warehouse:
item["to_warehouse"] = self.pro_doc.scrap_warehouse
def set_secondary_items(self):
if self.purpose in ["Manufacture", "Repack"]:
secondary_items_dict = self.get_secondary_items(self.fg_completed_qty)
for item in secondary_items_dict.values():
if self.pro_doc and item.type:
if self.pro_doc.scrap_warehouse and item.type == "Scrap":
item["to_warehouse"] = self.pro_doc.scrap_warehouse
self.add_to_stock_entry_detail(scrap_item_dict, bom_no=self.bom_no)
if item.process_loss_per:
item["qty"] -= flt(
item["qty"] * (item.process_loss_per / 100),
self.precision("fg_completed_qty"),
)
self.add_to_stock_entry_detail(secondary_items_dict, bom_no=self.bom_no)
def set_process_loss_qty(self):
if self.purpose not in ("Manufacture", "Repack"):
@@ -2600,7 +2612,7 @@ class StockEntry(StockController, SubcontractingInwardController):
fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}],
)
if data and data[0].process_loss_qty is not None:
if data and data[0].process_loss_qty:
process_loss_qty = data[0].process_loss_qty
if flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision):
self.process_loss_qty = flt(process_loss_qty, precision)
@@ -2632,7 +2644,7 @@ class StockEntry(StockController, SubcontractingInwardController):
if not self.pro_doc:
self.pro_doc = frappe.get_doc("Work Order", self.work_order)
if self.pro_doc:
if self.pro_doc and not self.pro_doc.track_semi_finished_goods:
self.bom_no = self.pro_doc.bom_no
else:
# invalid work order
@@ -2774,54 +2786,59 @@ class StockEntry(StockController, SubcontractingInwardController):
return item_dict
def get_bom_scrap_material(self, qty):
def get_secondary_items(self, qty):
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
if (
frappe.db.get_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies")
frappe.db.get_single_value(
"Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies"
)
and self.work_order
and frappe.get_cached_value("Work Order", self.work_order, "use_multi_level_bom")
):
item_dict = get_scrap_items_from_sub_assemblies(self.bom_no, self.company, qty)
item_dict = get_secondary_items_from_sub_assemblies(self.bom_no, self.company, qty)
else:
# item dict = { item_code: {qty, description, stock_uom} }
item_dict = (
get_bom_items_as_dict(
self.bom_no, self.company, qty=qty, fetch_exploded=0, fetch_scrap_items=1
self.bom_no, self.company, qty=qty, fetch_exploded=0, fetch_secondary_items=1
)
or {}
)
for item in item_dict.values():
item.from_warehouse = ""
item.is_scrap_item = 1
for row in self.get_scrap_items_from_job_card():
if row.stock_qty <= 0:
continue
item_row = item_dict.get(row.item_code)
if not item_row:
item_row = frappe._dict({})
item_row.update(
{
"uom": row.stock_uom,
"from_warehouse": "",
"qty": row.stock_qty + flt(item_row.stock_qty),
"converison_factor": 1,
"is_scrap_item": 1,
"item_name": row.item_name,
"description": row.description,
"allow_zero_valuation_rate": 1,
}
)
item_dict[row.item_code] = item_row
return item_dict
def get_scrap_items_from_job_card(self):
def set_secondary_items_from_job_card(self):
if self.purpose not in ["Manufacture", "Repack"]:
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(
{
"uom": row.stock_uom,
"from_warehouse": "",
"qty": row.stock_qty,
"conversion_factor": 1,
"type": row.type,
"item_name": row.item_name,
"description": row.description,
"bom_secondary_item": row.bom_secondary_item,
}
)
for item in item_dict.values():
item.from_warehouse = ""
self.add_to_stock_entry_detail(item_dict)
def get_secondary_items_from_job_card(self):
if not hasattr(self, "pro_doc"):
self.pro_doc = None
@@ -2832,70 +2849,78 @@ class StockEntry(StockController, SubcontractingInwardController):
return []
job_card = frappe.qb.DocType("Job Card")
job_card_scrap_item = frappe.qb.DocType("Job Card Scrap Item")
job_card_secondary_item = frappe.qb.DocType("Job Card Secondary Item")
scrap_items = (
other = (
frappe.qb.from_(job_card)
.select(
Sum(job_card_scrap_item.stock_qty).as_("stock_qty"),
job_card_scrap_item.item_code,
job_card_scrap_item.item_name,
job_card_scrap_item.description,
job_card_scrap_item.stock_uom,
Sum(job_card_secondary_item.stock_qty).as_("stock_qty"),
job_card_secondary_item.item_code,
job_card_secondary_item.item_name,
job_card_secondary_item.description,
job_card_secondary_item.stock_uom,
job_card_secondary_item.type,
job_card_secondary_item.bom_secondary_item,
)
.join(job_card_scrap_item)
.on(job_card_scrap_item.parent == job_card.name)
.join(job_card_secondary_item)
.on(job_card_secondary_item.parent == job_card.name)
.where(
(job_card_scrap_item.item_code.isnotnull())
(job_card_secondary_item.item_code.isnotnull())
& (job_card.work_order == self.work_order)
& (job_card.docstatus == 1)
)
.groupby(job_card_scrap_item.item_code)
.groupby(job_card_secondary_item.item_code, job_card_secondary_item.type)
.orderby(job_card_secondary_item.idx)
)
if self.job_card:
scrap_items = scrap_items.where(job_card.name == self.job_card)
other = other.where(job_card.name == self.job_card)
scrap_items = scrap_items.run(as_dict=1)
other = other.run(as_dict=1)
if self.job_card:
pending_qty = flt(self.fg_completed_qty)
else:
pending_qty = flt(self.get_completed_job_card_qty()) - flt(self.pro_doc.produced_qty)
used_scrap_items = self.get_used_scrap_items()
for row in scrap_items:
row.stock_qty -= flt(used_scrap_items.get(row.item_code))
used_secondary_items = self.get_used_secondary_items()
for row in other:
row.stock_qty -= flt(used_secondary_items.get(row.item_code))
row.stock_qty = (row.stock_qty) * flt(self.fg_completed_qty) / flt(pending_qty)
if used_scrap_items.get(row.item_code):
used_scrap_items[row.item_code] -= row.stock_qty
if used_secondary_items.get(row.item_code):
used_secondary_items[row.item_code] -= 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)
return scrap_items
return other
def get_completed_job_card_qty(self):
return flt(min([d.completed_qty for d in self.pro_doc.operations]))
def get_used_scrap_items(self):
used_scrap_items = defaultdict(float)
data = frappe.get_all(
"Stock Entry",
fields=["`tabStock Entry Detail`.`item_code`", "`tabStock Entry Detail`.`qty`"],
filters=[
["Stock Entry", "work_order", "=", self.work_order],
["Stock Entry Detail", "is_scrap_item", "=", 1],
["Stock Entry", "docstatus", "=", 1],
["Stock Entry", "purpose", "in", ["Repack", "Manufacture"]],
],
)
def get_used_secondary_items(self):
used_secondary_items = defaultdict(float)
StockEntry = frappe.qb.DocType("Stock Entry")
StockEntryDetail = frappe.qb.DocType("Stock Entry Detail")
data = (
frappe.qb.from_(StockEntry)
.inner_join(StockEntryDetail)
.on(StockEntryDetail.parent == StockEntry.name)
.select(StockEntryDetail.item_code, StockEntryDetail.qty)
.where(
(StockEntry.work_order == self.work_order)
& ((StockEntryDetail.type.isnotnull()) | (StockEntryDetail.is_legacy_scrap_item == 1))
& (StockEntry.docstatus == 1)
& (StockEntry.purpose.isin(["Repack", "Manufacture"]))
)
).run(as_dict=1)
for row in data:
used_scrap_items[row.item_code] += row.qty
used_secondary_items[row.item_code] += row.qty
return used_scrap_items
return used_secondary_items
def get_unconsumed_raw_materials(self):
wo = frappe.get_doc("Work Order", self.work_order)
@@ -3187,7 +3212,12 @@ class StockEntry(StockController, SubcontractingInwardController):
item_row = item_dict[d]
child_qty = flt(item_row["qty"], precision)
if not self.is_return and child_qty <= 0 and not item_row.get("is_scrap_item"):
if (
not self.is_return
and child_qty <= 0
and not item_row.get("type")
and not item_row.get("is_legacy_scrap_item")
):
if self.purpose not in ["Receive from Customer", "Send to Subcontractor"]:
continue
@@ -3205,11 +3235,13 @@ class StockEntry(StockController, SubcontractingInwardController):
item_row, company=self.company
)
se_child.is_finished_item = item_row.get("is_finished_item", 0)
se_child.is_scrap_item = item_row.get("is_scrap_item", 0)
se_child.po_detail = item_row.get("po_detail")
se_child.sco_rm_detail = item_row.get("sco_rm_detail")
se_child.scio_detail = item_row.get("scio_detail")
se_child.sample_quantity = item_row.get("sample_quantity", 0)
se_child.type = item_row.get("type")
se_child.is_legacy_scrap_item = item_row.get("is_legacy")
se_child.bom_secondary_item = item_row.get("name") or item_row.get("bom_secondary_item")
for field in [
self.subcontract_data.rm_detail_field,
@@ -3686,7 +3718,7 @@ def get_operating_cost_per_unit(work_order=None, bom_no=None):
if (
bom_no
and frappe.db.get_single_value(
"Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies"
"Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies"
)
and frappe.get_cached_value("Work Order", work_order.name, "use_multi_level_bom")
):

View File

@@ -909,8 +909,8 @@ class TestStockEntry(ERPNextTestSuite):
if d.s_warehouse:
rm_cost += d.amount
fg_cost = next(filter(lambda x: x.item_code == "_Test FG Item", s.get("items"))).amount
scrap_cost = next(filter(lambda x: x.is_scrap_item, s.get("items"))).amount
self.assertEqual(fg_cost, flt(rm_cost - scrap_cost, 2))
secondary_item_cost = next(filter(lambda x: x.type or x.is_legacy_scrap_item, s.get("items"))).amount
self.assertEqual(fg_cost, flt(rm_cost - secondary_item_cost, 2))
# When Stock Entry has only FG + Scrap
s.items.pop(0)
@@ -989,15 +989,15 @@ class TestStockEntry(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, ste.submit)
def test_quality_check_for_scrap_item(self):
def test_quality_check_for_secondary_item(self):
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as _make_stock_entry,
)
scrap_item = "_Test Scrap Item 1"
make_item(scrap_item, {"is_stock_item": 1, "is_purchase_item": 0})
secondary_item = "_Test Scrap Item 1"
make_item(secondary_item, {"is_stock_item": 1, "is_purchase_item": 0})
bom_name = frappe.db.get_value("BOM Scrap Item", {"docstatus": 1}, "parent")
bom_name = frappe.db.get_value("BOM Secondary Item", {"docstatus": 1}, "parent")
production_item = frappe.db.get_value("BOM", bom_name, "item")
work_order = frappe.new_doc("Work Order")
@@ -1027,18 +1027,18 @@ class TestStockEntry(ERPNextTestSuite):
basic_rate=row.basic_rate or 100,
)
if row.is_scrap_item:
row.item_code = scrap_item
row.uom = frappe.db.get_value("Item", scrap_item, "stock_uom")
row.stock_uom = frappe.db.get_value("Item", scrap_item, "stock_uom")
if row.type or row.is_legacy_scrap_item:
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")
stock_entry.inspection_required = 1
stock_entry.save()
self.assertTrue([row.item_code for row in stock_entry.items if row.is_scrap_item])
self.assertTrue([row.item_code for row in stock_entry.items if row.type or row.is_legacy_scrap_item])
for row in stock_entry.items:
if not row.is_scrap_item:
if not row.type and not row.is_legacy_scrap_item:
qc = frappe.get_doc(
{
"doctype": "Quality Inspection",
@@ -1058,7 +1058,7 @@ class TestStockEntry(ERPNextTestSuite):
stock_entry.reload()
stock_entry.submit()
for row in stock_entry.items:
if row.is_scrap_item:
if row.type or row.is_legacy_scrap_item:
self.assertFalse(row.quality_inspection)
else:
self.assertTrue(row.quality_inspection)
@@ -2464,6 +2464,35 @@ class TestStockEntry(ERPNextTestSuite):
# delete naming rule
frappe.delete_doc("Document Naming Rule", qc_naming_rule.name)
def test_co_by_product(self):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
frappe.set_value("UOM", "Nos", "must_be_whole_number", 0)
fg_item = make_item("FG Item", properties={"is_stock_item": 1}).name
rm_item = make_item("RM Item", properties={"is_stock_item": 1}).name
scrap_item = make_item("Scrap Item", properties={"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
make_stock_entry(item_code=rm_item, target=warehouse, qty=5, rate=10, purpose="Material Receipt")
bom_no = make_bom(
item=fg_item, raw_materials=[rm_item], scrap_items=[scrap_item], process_loss_percentage=10
).name
se = make_stock_entry(item_code=fg_item, qty=5, purpose="Manufacture", do_not_save=True)
se.from_bom = 1
se.bom_no = bom_no
se.fg_completed_qty = 5
se.from_warehouse = warehouse
se.to_warehouse = "_Test Warehouse 1 - _TC"
se.get_items()
se.save()
se.reload()
self.assertEqual(se.items[1].qty, 4.5)
self.assertEqual(se.items[1].amount, 45)
self.assertEqual(se.items[2].qty, 4.5)
self.assertEqual(se.items[2].amount, 5)
def make_serialized_item(self, **args):
args = frappe._dict(args)

View File

@@ -18,7 +18,8 @@
"item_name",
"col_break2",
"is_finished_item",
"is_scrap_item",
"is_legacy_scrap_item",
"type",
"quality_inspection",
"subcontracted_item",
"against_fg",
@@ -81,7 +82,8 @@
"putaway_rule",
"column_break_51",
"reference_purchase_receipt",
"job_card_item"
"job_card_item",
"bom_secondary_item"
],
"fields": [
{
@@ -558,12 +560,7 @@
},
{
"default": "0",
"fieldname": "is_scrap_item",
"fieldtype": "Check",
"label": "Is Scrap Item"
},
{
"default": "0",
"depends_on": "eval:!doc.is_legacy_scrap_item && !doc.type",
"fieldname": "is_finished_item",
"fieldtype": "Check",
"label": "Is Finished Item",
@@ -654,6 +651,28 @@
"no_copy": 1,
"options": "Subcontracting Inward Order Item",
"set_only_once": 1
},
{
"depends_on": "eval:parent.purpose == \"Manufacture\" && doc.t_warehouse && !doc.is_finished_item && !doc.is_legacy_scrap_item",
"fieldname": "type",
"fieldtype": "Select",
"label": "Type",
"options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good"
},
{
"fieldname": "bom_secondary_item",
"fieldtype": "Data",
"hidden": 1,
"label": "BOM Secondary Item",
"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
}
],
"grid_page_length": 50,

View File

@@ -26,6 +26,7 @@ class StockEntryDetail(Document):
basic_rate: DF.Currency
batch_no: DF.Link | None
bom_no: DF.Link | None
bom_secondary_item: DF.Data | None
conversion_factor: DF.Float
cost_center: DF.Link | None
customer_provided_item_cost: DF.Currency
@@ -34,7 +35,7 @@ class StockEntryDetail(Document):
has_item_scanned: DF.Check
image: DF.Attach | None
is_finished_item: DF.Check
is_scrap_item: DF.Check
is_legacy_scrap_item: DF.Check
item_code: DF.Link
item_group: DF.Data | None
item_name: DF.Data | None
@@ -66,6 +67,7 @@ class StockEntryDetail(Document):
t_warehouse: DF.Link | None
transfer_qty: DF.Float
transferred_qty: DF.Float
type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
uom: DF.Link
use_serial_batch_fields: DF.Check
valuation_rate: DF.Currency

View File

@@ -75,13 +75,18 @@ class ManufactureEntry:
self.stock_entry = frappe.new_doc("Stock Entry")
self.stock_entry.purpose = self.purpose
self.stock_entry.company = self.company
self.stock_entry.from_bom = 1
self.stock_entry.bom_no = self.bom_no
self.stock_entry.use_multi_level_bom = 1
if self.bom_no:
self.stock_entry.from_bom = 1
self.stock_entry.bom_no = self.bom_no
self.stock_entry.use_multi_level_bom = 1
self.stock_entry.fg_completed_qty = self.for_quantity
self.stock_entry.process_loss_qty = self.process_loss_qty
self.stock_entry.project = self.project
self.stock_entry.job_card = self.job_card
self.stock_entry.set_stock_entry_type()
self.stock_entry.work_order = self.work_order
self.prepare_source_warehouse()
self.add_raw_materials()
@@ -303,7 +308,7 @@ class ManufactureEntry:
args = {
"to_warehouse": self.fg_warehouse,
"from_warehouse": "",
"qty": self.for_quantity,
"qty": self.for_quantity - self.process_loss_qty,
"item_name": item.item_name,
"description": item.description,
"stock_uom": item.stock_uom,

View File

@@ -25,7 +25,7 @@
"raw_materials_received_section",
"received_items",
"scrap_items_generated_section",
"scrap_items",
"secondary_items",
"service_items_section",
"service_items",
"tab_other_info",
@@ -252,17 +252,10 @@
"reqd": 1
},
{
"depends_on": "scrap_items",
"depends_on": "secondary_items",
"fieldname": "scrap_items_generated_section",
"fieldtype": "Section Break",
"label": "Scrap Items Generated"
},
{
"fieldname": "scrap_items",
"fieldtype": "Table",
"label": "Scrap Items",
"no_copy": 1,
"options": "Subcontracting Inward Order Scrap Item"
"label": "Secondary Items Generated"
},
{
"fieldname": "per_returned",
@@ -300,13 +293,20 @@
"label": "Customer Currency",
"options": "Currency",
"read_only": 1
},
{
"fieldname": "secondary_items",
"fieldtype": "Table",
"label": "Secondary Items",
"no_copy": 1,
"options": "Subcontracting Inward Order Secondary Item"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-12-09 15:52:55.781346",
"modified": "2026-02-26 17:16:21.697846",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Inward Order",

View File

@@ -25,8 +25,8 @@ class SubcontractingInwardOrder(SubcontractingController):
from erpnext.subcontracting.doctype.subcontracting_inward_order_received_item.subcontracting_inward_order_received_item import (
SubcontractingInwardOrderReceivedItem,
)
from erpnext.subcontracting.doctype.subcontracting_inward_order_scrap_item.subcontracting_inward_order_scrap_item import (
SubcontractingInwardOrderScrapItem,
from erpnext.subcontracting.doctype.subcontracting_inward_order_secondary_item.subcontracting_inward_order_secondary_item import (
SubcontractingInwardOrderSecondaryItem,
)
from erpnext.subcontracting.doctype.subcontracting_inward_order_service_item.subcontracting_inward_order_service_item import (
SubcontractingInwardOrderServiceItem,
@@ -48,7 +48,7 @@ class SubcontractingInwardOrder(SubcontractingController):
per_returned: DF.Percent
received_items: DF.Table[SubcontractingInwardOrderReceivedItem]
sales_order: DF.Link
scrap_items: DF.Table[SubcontractingInwardOrderScrapItem]
secondary_items: DF.Table[SubcontractingInwardOrderSecondaryItem]
service_items: DF.Table[SubcontractingInwardOrderServiceItem]
set_delivery_warehouse: DF.Link | None
status: DF.Literal[
@@ -474,23 +474,25 @@ class SubcontractingInwardOrder(SubcontractingController):
stock_entry.add_to_stock_entry_detail(items_dict)
if (
frappe.get_single_value("Selling Settings", "deliver_scrap_items")
and self.scrap_items
frappe.get_single_value("Selling Settings", "deliver_secondary_items")
and self.secondary_items
and scio_details
):
scrap_items = [
scrap_item for scrap_item in self.scrap_items if scrap_item.reference_name in scio_details
secondary_items = [
secondary_item
for secondary_item in self.secondary_items
if secondary_item.reference_name in scio_details
]
for scrap_item in scrap_items:
qty = scrap_item.produced_qty - scrap_item.delivered_qty
for secondary_item in secondary_items:
qty = secondary_item.produced_qty - secondary_item.delivered_qty
if qty > 0:
items_dict = {
scrap_item.item_code: {
"qty": scrap_item.produced_qty - scrap_item.delivered_qty,
"from_warehouse": scrap_item.warehouse,
"stock_uom": scrap_item.stock_uom,
"scio_detail": scrap_item.name,
"is_scrap_item": 1,
secondary_item.item_code: {
"qty": secondary_item.produced_qty - secondary_item.delivered_qty,
"from_warehouse": secondary_item.warehouse,
"stock_uom": secondary_item.stock_uom,
"scio_detail": secondary_item.name,
"type": secondary_item.type,
}
}

View File

@@ -323,10 +323,12 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite):
delivery.items[0].qty = 6
self.assertRaises(frappe.ValidationError, delivery.submit)
@ERPNextTestSuite.change_settings("Selling Settings", {"deliver_scrap_items": 1})
@ERPNextTestSuite.change_settings("Selling Settings", {"deliver_secondary_items": 1})
def test_secondary_items_delivery(self):
new_bom = frappe.copy_doc(frappe.get_doc("BOM", "BOM-Basic FG Item-001"))
new_bom.scrap_items.append(frappe.new_doc("BOM Scrap Item", item_code="Basic RM 2", qty=1))
new_bom.secondary_items.append(
frappe.new_doc("BOM Secondary Item", item_code="Basic RM 2", qty=1, type="Scrap")
)
new_bom.submit()
sc_bom = frappe.get_doc("Subcontracting BOM", "SB-0001")
sc_bom.finished_good_bom = new_bom.name
@@ -343,12 +345,12 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite):
frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture")).submit()
scio.reload()
self.assertEqual(scio.scrap_items[0].item_code, "Basic RM 2")
self.assertEqual(scio.secondary_items[0].item_code, "Basic RM 2")
delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery())
self.assertEqual(delivery.items[-1].item_code, "Basic RM 2")
frappe.db.set_single_value("Selling Settings", "deliver_scrap_items", 0)
frappe.db.set_single_value("Selling Settings", "deliver_secondary_items", 0)
delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery())
self.assertNotEqual(delivery.items[-1].item_code, "Basic RM 2")

View File

@@ -6,13 +6,15 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"column_break_rptg",
"type",
"reference_name",
"column_break_jkzt",
"item_code",
"fg_item_code",
"column_break_hoxe",
"stock_uom",
"warehouse",
"column_break_rptg",
"reference_name",
"section_break_gqk9",
"produced_qty",
"column_break_n4xc",
@@ -93,16 +95,29 @@
{
"fieldname": "column_break_n4xc",
"fieldtype": "Column Break"
},
{
"fieldname": "type",
"fieldtype": "Select",
"label": "Type",
"no_copy": 1,
"options": "Co-Product\nBy-Product\nScrap\nAdditional Finished Good",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "column_break_jkzt",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-14 10:28:30.192350",
"modified": "2026-02-27 15:15:40.009957",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Inward Order Scrap Item",
"name": "Subcontracting Inward Order Secondary Item",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",

View File

@@ -5,7 +5,7 @@
from frappe.model.document import Document
class SubcontractingInwardOrderScrapItem(Document):
class SubcontractingInwardOrderSecondaryItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -23,6 +23,7 @@ class SubcontractingInwardOrderScrapItem(Document):
produced_qty: DF.Float
reference_name: DF.Data
stock_uom: DF.Link
type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
warehouse: DF.Link
# end: auto-generated types

View File

@@ -439,6 +439,13 @@ def get_mapped_subcontracting_receipt(source_name, target_doc=None, items=None):
target.purchase_order = source_parent.purchase_order
target.purchase_order_item = source.purchase_order_item
target.qty = items.get(source.name) or (flt(source.qty) - flt(source.received_qty))
target.received_qty = target.qty
if process_loss_per := frappe.get_value("BOM", source.bom, "process_loss_percentage"):
target.process_loss_qty = flt(
target.qty * (process_loss_per / 100), target.precision("process_loss_qty")
)
target.qty -= target.process_loss_qty
target.amount = (flt(source.qty) - flt(source.received_qty)) * flt(source.rate)
items = {item["name"]: item["qty"] for item in items} if items else {}

View File

@@ -425,7 +425,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-03 12:29:45.156101",
"modified": "2026-02-27 23:03:36.436504",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Order Item",

View File

@@ -174,6 +174,7 @@ frappe.ui.form.on("Subcontracting Receipt", {
frm.trigger("setup_quality_inspection");
frm.trigger("set_route_options_for_new_doc");
frm.set_df_property("items", "cannot_add_rows", true);
},
set_warehouse: (frm) => {
@@ -184,15 +185,15 @@ frappe.ui.form.on("Subcontracting Receipt", {
set_warehouse_in_children(frm.doc.items, "rejected_warehouse", frm.doc.rejected_warehouse);
},
get_scrap_items: (frm) => {
get_secondary_items: (frm) => {
frappe.call({
doc: frm.doc,
method: "get_scrap_items",
method: "get_secondary_items",
args: {
recalculate_rate: true,
},
freeze: true,
freeze_message: __("Getting Scrap Items"),
freeze_message: __("Getting Secondary Items"),
callback: (r) => {
if (!r.exc) {
frm.refresh();
@@ -422,11 +423,19 @@ frappe.ui.form.on("Subcontracting Receipt Item", {
set_missing_values(frm);
},
rejected_qty(frm) {
set_missing_values(frm);
},
process_loss_qty(frm) {
set_missing_values(frm);
},
rate(frm) {
set_missing_values(frm);
},
items_delete: (frm) => {
items_delete(frm) {
set_missing_values(frm);
},

View File

@@ -29,8 +29,8 @@
"col_break_warehouse",
"supplier_warehouse",
"items_section",
"get_scrap_items",
"items",
"get_secondary_items",
"section_break0",
"total_qty",
"column_break_27",
@@ -631,13 +631,6 @@
"label": "Edit Posting Date and Time",
"print_hide": 1
},
{
"depends_on": "eval: (!doc.__islocal && doc.docstatus == 0)",
"fieldname": "get_scrap_items",
"fieldtype": "Button",
"label": "Get Scrap Items",
"options": "get_scrap_items"
},
{
"fieldname": "supplier_delivery_note",
"fieldtype": "Data",
@@ -674,12 +667,19 @@
"fieldtype": "Tab Break",
"label": "Connections",
"show_dashboard": 1
},
{
"depends_on": "eval: (!doc.__islocal && doc.docstatus == 0)",
"fieldname": "get_secondary_items",
"fieldtype": "Button",
"label": "Get Secondary Items",
"options": "get_secondary_items"
}
],
"in_create": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-10-08 21:43:27.065640",
"modified": "2026-02-27 17:59:44.107193",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Receipt",

View File

@@ -144,12 +144,12 @@ class SubcontractingReceipt(SubcontractingController):
super().validate()
if self.is_new() and self.get("_action") == "save" and not frappe.in_test:
self.get_scrap_items()
self.get_secondary_items()
self.set_missing_values()
if self.get("_action") == "submit":
self.validate_scrap_items()
self.validate_secondary_items()
self.validate_accepted_warehouse()
self.validate_rejected_warehouse()
@@ -343,39 +343,66 @@ class SubcontractingReceipt(SubcontractingController):
self.update_rate_for_supplied_items()
@frappe.whitelist()
def get_scrap_items(self, recalculate_rate=False):
self.remove_scrap_items()
def get_secondary_items(self, recalculate_rate: bool | None = False):
self.remove_secondary_items()
for item in list(self.items):
if item.bom:
bom = frappe.get_doc("BOM", item.bom)
for scrap_item in bom.scrap_items:
qty = flt(item.qty) * (flt(scrap_item.stock_qty) / flt(bom.quantity))
rate = (
get_valuation_rate(
scrap_item.item_code,
self.set_warehouse,
self.doctype,
self.name,
currency=erpnext.get_company_currency(self.company),
company=self.company,
)
or scrap_item.rate
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",
{
"is_scrap_item": 1,
"type": secondary_item.type,
"is_legacy_scrap_item": secondary_item.is_legacy,
"reference_name": item.name,
"item_code": scrap_item.item_code,
"item_name": scrap_item.item_name,
"qty": qty,
"stock_uom": scrap_item.stock_uom,
"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,
"scrap_cost_per_qty": 0,
"secondary_items_cost_per_qty": 0,
"amount": qty * rate,
"warehouse": self.set_warehouse,
"rejected_warehouse": self.rejected_warehouse,
@@ -386,15 +413,12 @@ class SubcontractingReceipt(SubcontractingController):
self.calculate_additional_costs()
self.calculate_items_qty_and_amount()
def remove_scrap_items(self, recalculate_rate=False):
def remove_secondary_items(self):
for item in list(self.items):
if item.is_scrap_item:
if item.type or item.is_legacy_scrap_item:
self.remove(item)
else:
item.scrap_cost_per_qty = 0
if recalculate_rate:
self.calculate_items_qty_and_amount()
item.secondary_items_cost_per_qty = 0
@frappe.whitelist()
def set_missing_values(self):
@@ -449,30 +473,35 @@ class SubcontractingReceipt(SubcontractingController):
else:
rm_cost_map[item.reference_name] = item.amount
scrap_cost_map = {}
secondary_items_cost_map = {}
for item in self.get("items") or []:
if item.is_scrap_item:
item.amount = flt(item.qty) * flt(item.rate)
if 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 item.reference_name in scrap_cost_map:
scrap_cost_map[item.reference_name] += item.amount
if item.reference_name in secondary_items_cost_map:
secondary_items_cost_map[item.reference_name] += item.amount
else:
scrap_cost_map[item.reference_name] = item.amount
secondary_items_cost_map[item.reference_name] = item.amount
total_qty = total_amount = 0
for item in self.get("items") or []:
if not item.is_scrap_item:
if not item.type and not item.is_legacy_scrap_item:
if item.qty:
if item.name in rm_cost_map:
item.rm_supp_cost = rm_cost_map[item.name]
item.rm_cost_per_qty = item.rm_supp_cost / item.qty
item.rm_cost_per_qty = item.rm_supp_cost / (item.received_qty or item.qty)
rm_cost_map.pop(item.name)
if item.name in scrap_cost_map:
item.scrap_cost_per_qty = scrap_cost_map[item.name] / item.qty
scrap_cost_map.pop(item.name)
if item.name in secondary_items_cost_map:
item.secondary_items_cost_per_qty = secondary_items_cost_map[item.name] / item.qty
secondary_items_cost_map.pop(item.name)
else:
item.scrap_cost_per_qty = 0
item.secondary_items_cost_per_qty = 0
lcv_cost_per_qty = 0.0
if item.landed_cost_voucher_amount:
@@ -483,36 +512,44 @@ class SubcontractingReceipt(SubcontractingController):
+ flt(item.service_cost_per_qty)
+ flt(item.additional_cost_per_qty)
+ flt(lcv_cost_per_qty)
- flt(item.scrap_cost_per_qty)
)
item.received_qty = flt(item.qty) + flt(item.rejected_qty)
item.amount = flt(item.qty) * flt(item.rate)
if item.bom:
item.received_qty = flt(item.qty) + flt(item.rejected_qty) + flt(item.process_loss_qty)
item.amount = (
flt(item.received_qty)
* flt(item.rate)
* (frappe.get_value("BOM", item.bom, "cost_allocation_per") / 100)
)
item.rate = item.amount / (item.qty or item.rejected_qty)
else:
item.qty = flt(item.received_qty) - flt(item.process_loss_qty)
item.amount = flt(item.qty) * flt(item.rate)
total_qty += flt(item.qty)
total_qty += flt(item.qty) + flt(item.rejected_qty)
total_amount += item.amount
else:
self.total_qty = total_qty
self.total = total_amount
def validate_scrap_items(self):
def validate_secondary_items(self):
for item in self.items:
if item.is_scrap_item:
if item.type or item.is_legacy_scrap_item:
if not item.qty:
frappe.throw(
_("Row #{0}: Scrap Item Qty cannot be zero").format(item.idx),
_("Row #{0}: Secondary Item Qty cannot be zero").format(item.idx),
)
if item.rejected_qty:
frappe.throw(
_("Row #{0}: Rejected Qty cannot be set for Scrap Item {1}.").format(
_("Row #{0}: Rejected Qty cannot be set for Secondary Item {1}.").format(
item.idx, frappe.bold(item.item_code)
),
)
if not item.reference_name:
frappe.throw(
_("Row #{0}: Finished Good reference is mandatory for Scrap Item {1}.").format(
_("Row #{0}: Finished Good reference is mandatory for Secondary Item {1}.").format(
item.idx, frappe.bold(item.item_code)
),
)

View File

@@ -597,6 +597,7 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
scr.items[0].qty = 6 # Accepted Qty
scr.items[0].rejected_qty = 4
scr.set_missing_values()
scr.save()
# consumed_qty should be (accepted_qty * qty_consumed_per_unit) = (6 * 1) = 6
@@ -1154,7 +1155,7 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
# ValidationError should not be raised as `Inspection Required before Purchase` is disabled
scr2.submit()
def test_scrap_items_for_subcontracting_receipt(self):
def test_secondary_items_for_subcontracting_receipt(self):
set_backflush_based_on("BOM")
fg_item = "Subcontracted Item SA1"
@@ -1166,9 +1167,9 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
]
# Create Scrap Items
scrap_item_1 = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
scrap_item_2 = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name
scrap_items = [scrap_item_1, scrap_item_2]
secondary_item_1 = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
secondary_item_2 = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name
secondary_items = [secondary_item_1, secondary_item_2]
service_items = [
{
@@ -1187,13 +1188,14 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
)
for idx, item in enumerate(bom.items):
item.qty = 1 * (idx + 1)
for idx, item in enumerate(scrap_items):
for idx, item in enumerate(secondary_items):
bom.append(
"scrap_items",
"secondary_items",
{
"item_code": item,
"stock_qty": 1 * (idx + 1),
"rate": 10 * (idx + 1),
"is_legacy": 1,
},
)
bom.save()
@@ -1216,12 +1218,13 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
# Create Subcontracting Receipt
scr = make_subcontracting_receipt(sco.name)
scr.save()
scr.get_scrap_items()
scr.get_secondary_items()
# Test - 1: Scrap Items should be fetched from BOM in items table with `is_scrap_item` = 1
scr_scrap_items = set([item.item_code for item in scr.items if item.is_scrap_item])
scr_secondary_items = set(
[item.item_code for item in scr.items if item.type or item.is_legacy_scrap_item]
)
self.assertEqual(len(scr.items), 3) # 1 FG Item + 2 Scrap Items
self.assertEqual(scr_scrap_items, set(scrap_items))
self.assertEqual(scr_secondary_items, set(secondary_items))
scr.submit()

View File

@@ -8,9 +8,10 @@
"engine": "InnoDB",
"field_order": [
"item_code",
"is_legacy_scrap_item",
"type",
"column_break_2",
"item_name",
"is_scrap_item",
"section_break_4",
"description",
"brand",
@@ -22,6 +23,7 @@
"qty",
"rejected_qty",
"returned_qty",
"process_loss_qty",
"col_break2",
"stock_uom",
"conversion_factor",
@@ -33,7 +35,7 @@
"rm_cost_per_qty",
"service_cost_per_qty",
"additional_cost_per_qty",
"scrap_cost_per_qty",
"secondary_items_cost_per_qty",
"rm_supp_cost",
"warehouse_and_reference",
"warehouse",
@@ -144,7 +146,7 @@
"default": "0",
"fieldname": "received_qty",
"fieldtype": "Float",
"label": "Received Quantity",
"label": "Qty (As per BOM)",
"no_copy": 1,
"print_hide": 1,
"print_width": "100px",
@@ -157,22 +159,23 @@
"fieldname": "qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Accepted Quantity",
"label": "Accepted Qty",
"no_copy": 1,
"print_width": "100px",
"read_only_depends_on": "eval:doc.type || doc.is_legacy_scrap_item",
"width": "100px"
},
{
"columns": 1,
"depends_on": "eval: !parent.is_return",
"depends_on": "eval:!parent.is_return && !doc.type && !doc.is_legacy_scrap_item",
"fieldname": "rejected_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Rejected Quantity",
"label": "Rejected Qty",
"no_copy": 1,
"print_hide": 1,
"print_width": "100px",
"read_only_depends_on": "eval: doc.is_scrap_item",
"read_only_depends_on": "eval:doc.type || doc.is_legacy_scrap_item",
"width": "100px"
},
{
@@ -181,6 +184,7 @@
"print_hide": 1
},
{
"fetch_from": "item_code.stock_uom",
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
@@ -230,7 +234,7 @@
},
{
"default": "0",
"depends_on": "eval: !doc.is_scrap_item",
"depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item",
"fieldname": "rm_cost_per_qty",
"fieldtype": "Currency",
"label": "Raw Material Cost Per Qty",
@@ -240,7 +244,7 @@
},
{
"default": "0",
"depends_on": "eval: !doc.is_scrap_item",
"depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item",
"fieldname": "service_cost_per_qty",
"fieldtype": "Currency",
"label": "Service Cost Per Qty",
@@ -250,7 +254,7 @@
},
{
"default": "0",
"depends_on": "eval: !doc.is_scrap_item",
"depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item",
"fieldname": "additional_cost_per_qty",
"fieldtype": "Currency",
"label": "Additional Cost Per Qty",
@@ -274,7 +278,7 @@
"width": "100px"
},
{
"depends_on": "eval: !parent.is_return",
"depends_on": "eval: !parent.is_return && !doc.type && !doc.is_legacy_scrap_item",
"fieldname": "rejected_warehouse",
"fieldtype": "Link",
"ignore_user_permissions": 1,
@@ -283,11 +287,10 @@
"options": "Warehouse",
"print_hide": 1,
"print_width": "100px",
"read_only_depends_on": "eval: doc.is_scrap_item",
"width": "100px"
},
{
"depends_on": "eval:!doc.__islocal",
"depends_on": "eval:!doc.__islocal && !doc.type && !doc.is_legacy_scrap_item",
"fieldname": "quality_inspection",
"fieldtype": "Link",
"label": "Quality Inspection",
@@ -369,7 +372,7 @@
"no_copy": 1,
"options": "BOM",
"print_hide": 1,
"read_only_depends_on": "eval: doc.is_scrap_item"
"read_only_depends_on": "eval:doc.type || doc.is_legacy_scrap_item"
},
{
"fetch_from": "item_code.brand",
@@ -496,7 +499,7 @@
"print_hide": 1
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1",
"depends_on": "eval:(doc.use_serial_batch_fields === 0 || doc.docstatus === 1) && !doc.type && !doc.is_legacy_scrap_item",
"fieldname": "rejected_serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Rejected Serial and Batch Bundle",
@@ -504,26 +507,6 @@
"options": "Serial and Batch Bundle",
"print_hide": 1
},
{
"default": "0",
"depends_on": "eval: !doc.bom",
"fieldname": "is_scrap_item",
"fieldtype": "Check",
"label": "Is Scrap Item",
"no_copy": 1,
"print_hide": 1,
"read_only_depends_on": "eval: doc.bom"
},
{
"default": "0",
"depends_on": "eval: !doc.is_scrap_item",
"fieldname": "scrap_cost_per_qty",
"fieldtype": "Float",
"label": "Scrap Cost Per Qty",
"no_copy": 1,
"non_negative": 1,
"read_only": 1
},
{
"fieldname": "reference_name",
"fieldtype": "Data",
@@ -553,6 +536,7 @@
},
{
"default": "0",
"depends_on": "eval:doc.bom",
"fieldname": "include_exploded_items",
"fieldtype": "Check",
"label": "Include Exploded Items",
@@ -580,7 +564,7 @@
"label": "Add Serial / Batch Bundle"
},
{
"depends_on": "eval:doc.use_serial_batch_fields === 0",
"depends_on": "eval:doc.use_serial_batch_fields === 0 && !doc.type && !doc.is_legacy_scrap_item",
"fieldname": "add_serial_batch_for_rejected_qty",
"fieldtype": "Button",
"label": "Add Serial / Batch No (Rejected Qty)"
@@ -594,6 +578,7 @@
"search_index": 1
},
{
"depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item",
"fieldname": "landed_cost_voucher_amount",
"fieldtype": "Currency",
"label": "Landed Cost Voucher Amount",
@@ -609,13 +594,48 @@
"fieldtype": "Link",
"label": "Service Expense Account",
"options": "Account"
},
{
"fieldname": "type",
"fieldtype": "Select",
"label": "Type",
"no_copy": 1,
"options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good",
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item",
"fieldname": "secondary_items_cost_per_qty",
"fieldtype": "Currency",
"label": "Secondary Items Cost Per Qty",
"no_copy": 1,
"non_negative": 1,
"options": "Company:company:default_currency",
"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
},
{
"default": "0",
"fieldname": "process_loss_qty",
"fieldtype": "Float",
"label": "Process Loss Qty",
"non_negative": 1
}
],
"grid_page_length": 50,
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-09-26 12:00:38.877638",
"modified": "2026-03-09 15:11:16.977539",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Receipt Item",

View File

@@ -25,7 +25,7 @@ class SubcontractingReceiptItem(Document):
expense_account: DF.Link | None
image: DF.Attach | None
include_exploded_items: DF.Check
is_scrap_item: DF.Check
is_legacy_scrap_item: DF.Check
item_code: DF.Link
item_name: DF.Data | None
job_card: DF.Link | None
@@ -36,6 +36,7 @@ class SubcontractingReceiptItem(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
process_loss_qty: DF.Float
project: DF.Link | None
purchase_order: DF.Link | None
purchase_order_item: DF.Data | None
@@ -52,7 +53,7 @@ class SubcontractingReceiptItem(Document):
rm_cost_per_qty: DF.Currency
rm_supp_cost: DF.Currency
schedule_date: DF.Date | None
scrap_cost_per_qty: DF.Float
secondary_items_cost_per_qty: DF.Currency
serial_and_batch_bundle: DF.Link | None
serial_no: DF.SmallText | None
service_cost_per_qty: DF.Currency
@@ -61,6 +62,7 @@ class SubcontractingReceiptItem(Document):
subcontracting_order: DF.Link | None
subcontracting_order_item: DF.Data | None
subcontracting_receipt_item: DF.Data | None
type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
use_serial_batch_fields: DF.Check
warehouse: DF.Link | None
# end: auto-generated types