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: elif self.doctype == "Stock Entry" and row.t_warehouse:
qi_required = True # inward stock needs inspection 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 continue
if qi_required: # validate row only if inspection is required on item level 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)) ).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: if not is_sub_contracted_item:
frappe.throw( frappe.throw(
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name) _("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) ).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 item.amount = item.qty * item.rate
if item.bom: if item.bom:
@@ -238,7 +238,7 @@ class SubcontractingController(StockController):
and self._doc_before_save and self._doc_before_save
): ):
for row in self._doc_before_save.get("items"): 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 return item_dict
@@ -264,7 +264,7 @@ class SubcontractingController(StockController):
self.__reference_name.append(row.name) self.__reference_name.append(row.name)
if (row.name not in item_dict) or ( if (row.name not in item_dict) or (
row.item_code, row.item_code,
row.qty + (row.get("rejected_qty") or 0), row.received_qty,
) != item_dict[row.name]: ) != item_dict[row.name]:
self.__changed_name.append(row.name) self.__changed_name.append(row.name)
@@ -962,7 +962,7 @@ class SubcontractingController(StockController):
): ):
qty = ( qty = (
flt(bom_item.qty_consumed_per_unit) 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 * row.conversion_factor
) )
bom_item.main_item_code = row.item_code bom_item.main_item_code = row.item_code
@@ -1285,22 +1285,28 @@ class SubcontractingController(StockController):
if self.total_additional_costs: if self.total_additional_costs:
if self.distribute_additional_costs_based_on == "Amount": if self.distribute_additional_costs_based_on == "Amount":
total_amt = sum( 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: 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.additional_cost_per_qty = (
(item.amount * self.total_additional_costs) / total_amt (item.amount * self.total_additional_costs) / total_amt
) / item.qty ) / item.qty
else: 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 additional_cost_per_qty = self.total_additional_costs / total_qty
for item in self.items: 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 item.additional_cost_per_qty = additional_cost_per_qty
else: else:
for item in self.items: 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 item.additional_cost_per_qty = 0
@frappe.whitelist() @frappe.whitelist()

View File

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

View File

@@ -501,8 +501,8 @@ class TestSubcontractingController(ERPNextTestSuite):
scr1.items[0].qty = 2 scr1.items[0].qty = 2
add_second_row_in_scr(scr1) add_second_row_in_scr(scr1)
scr1.flags.ignore_mandatory = True scr1.flags.ignore_mandatory = True
scr1.save()
scr1.set_missing_values() scr1.set_missing_values()
scr1.save()
scr1.submit() scr1.submit()
for _key, value in get_supplied_items(scr1).items(): for _key, value in get_supplied_items(scr1).items():
@@ -513,8 +513,8 @@ class TestSubcontractingController(ERPNextTestSuite):
scr2.items[0].qty = 2 scr2.items[0].qty = 2
add_second_row_in_scr(scr2) add_second_row_in_scr(scr2)
scr2.flags.ignore_mandatory = True scr2.flags.ignore_mandatory = True
scr2.save()
scr2.set_missing_values() scr2.set_missing_values()
scr2.save()
scr2.submit() scr2.submit()
for _key, value in get_supplied_items(scr2).items(): for _key, value in get_supplied_items(scr2).items():
@@ -523,8 +523,8 @@ class TestSubcontractingController(ERPNextTestSuite):
scr3 = make_subcontracting_receipt(sco.name) scr3 = make_subcontracting_receipt(sco.name)
scr3.items[0].qty = 2 scr3.items[0].qty = 2
scr3.flags.ignore_mandatory = True scr3.flags.ignore_mandatory = True
scr3.save()
scr3.set_missing_values() scr3.set_missing_values()
scr3.save()
scr3.submit() scr3.submit()
for _key, value in get_supplied_items(scr3).items(): 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) 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): def add_second_row_in_scr(scr):
item_dict = {} item_dict = {}

View File

@@ -620,10 +620,10 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
} }
item_code(doc, cdt, cdn) { item_code(doc, cdt, cdn) {
var scrap_items = false; let secondary_items = false;
var child = locals[cdt][cdn]; var child = locals[cdt][cdn];
if (child.doctype == "BOM Scrap Item") { if (child.doctype == "BOM Secondary Item") {
scrap_items = true; secondary_items = true;
} }
if (child.bom_no) { if (child.bom_no) {
@@ -634,7 +634,7 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
child.do_not_explode = 1; 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) { 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); 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) { if (!doc.company) {
frappe.throw({ message: __("Please select a Company first."), title: __("Mandatory") }); 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, company: doc.company,
item_code: d.item_code, item_code: d.item_code,
bom_no: d.bom_no != null ? d.bom_no : "", bom_no: d.bom_no != null ? d.bom_no : "",
scrap_items: scrap_items,
qty: d.qty, qty: d.qty,
stock_qty: d.stock_qty, stock_qty: d.stock_qty,
include_item_in_manufacturing: d.include_item_in_manufacturing, 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, conversion_factor: d.conversion_factor,
sourced_by_supplier: d.sourced_by_supplier, sourced_by_supplier: d.sourced_by_supplier,
do_not_explode: d.do_not_explode, do_not_explode: d.do_not_explode,
fetch_rate: !secondary_items,
}, },
callback: function (r) { callback: function (r) {
$.extend(d, r.message); $.extend(d, r.message);
refresh_field("items"); refresh_field("items");
refresh_field("scrap_items"); refresh_field("secondary_items");
doc = locals[doc.doctype][doc.name]; doc = locals[doc.doctype][doc.name];
erpnext.bom.calculate_rm_cost(doc); erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(doc); erpnext.bom.calculate_total(doc);
}, },
freeze: true, freeze: true,
@@ -724,20 +723,18 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) {
cur_frm.cscript.qty = function (doc) { cur_frm.cscript.qty = function (doc) {
erpnext.bom.calculate_rm_cost(doc); erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(doc); erpnext.bom.calculate_total(doc);
}; };
cur_frm.cscript.rate = function (doc, cdt, cdn) { cur_frm.cscript.rate = function (doc, cdt, cdn) {
var d = locals[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) { if (d.bom_no) {
frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item.")); 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 { } else {
erpnext.bom.calculate_rm_cost(doc); erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(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.update_cost = function (doc) {
erpnext.bom.calculate_op_cost(doc); erpnext.bom.calculate_op_cost(doc);
erpnext.bom.calculate_rm_cost(doc); erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_scrap_materials_cost(doc);
erpnext.bom.calculate_total(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); 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 // Calculate Total Cost
erpnext.bom.calculate_total = function (doc) { 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 = 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("total_cost", total_cost);
cur_frm.set_value("base_total_cost", base_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) { item_code(frm, cdt, cdn) {
const { item_code } = locals[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]; const row = locals[cdt][cdn];
row.stock_qty = (frm.doc.quantity * data.percent) / 100; row.stock_qty = (frm.doc.quantity * data.percent) / 100;
row.qty = row.stock_qty / (row.conversion_factor || 1); row.qty = row.stock_qty / (row.conversion_factor || 1);
refresh_field("scrap_items"); refresh_field("secondary_items");
}, },
__("Set Process Loss Item Quantity"), __("Set Process Loss Item Quantity"),
__("Set Quantity") __("Set Quantity")

View File

@@ -16,6 +16,14 @@
"allow_alternative_item", "allow_alternative_item",
"set_rate_of_sub_assembly_item_based_on_bom", "set_rate_of_sub_assembly_item_based_on_bom",
"is_phantom_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", "currency_detail",
"rm_cost_as_per", "rm_cost_as_per",
"buying_price_list", "buying_price_list",
@@ -38,21 +46,16 @@
"operations", "operations",
"materials_section", "materials_section",
"items", "items",
"scrap_section", "secondary_items_tab",
"scrap_items_section", "secondary_items",
"scrap_items",
"process_loss_section",
"process_loss_percentage",
"column_break_ssj2",
"process_loss_qty",
"costing", "costing",
"operating_cost", "operating_cost",
"raw_material_cost", "raw_material_cost",
"scrap_material_cost", "secondary_items_cost",
"cb1", "cb1",
"base_operating_cost", "base_operating_cost",
"base_raw_material_cost", "base_raw_material_cost",
"base_scrap_material_cost", "base_secondary_items_cost",
"column_break_26", "column_break_26",
"total_cost", "total_cost",
"base_total_cost", "base_total_cost",
@@ -298,19 +301,6 @@
"options": "BOM Item", "options": "BOM Item",
"reqd": 1 "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", "fieldname": "costing",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
@@ -332,15 +322,6 @@
"options": "currency", "options": "currency",
"read_only": 1 "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", "fieldname": "cb1",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@@ -362,15 +343,6 @@
"print_hide": 1, "print_hide": 1,
"read_only": 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", "fieldname": "total_cost",
"fieldtype": "Currency", "fieldtype": "Currency",
@@ -602,12 +574,6 @@
"fieldname": "column_break_ivyw", "fieldname": "column_break_ivyw",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "scrap_items_section",
"fieldtype": "Section Break",
"hide_border": 1,
"label": "Scrap Items"
},
{ {
"default": "0", "default": "0",
"fieldname": "fg_based_operating_cost", "fieldname": "fg_based_operating_cost",
@@ -706,6 +672,59 @@
"fieldname": "quality_inspection_tab", "fieldname": "quality_inspection_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Quality Inspection" "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", "icon": "fa fa-sitemap",
@@ -713,7 +732,7 @@
"image_field": "image", "image_field": "image",
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-02-06 17:23:15.255301", "modified": "2026-02-26 14:13:34.040181",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "BOM", "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_explosion_item.bom_explosion_item import BOMExplosionItem
from erpnext.manufacturing.doctype.bom_item.bom_item import BOMItem 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_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 allow_alternative_item: DF.Check
amended_from: DF.Link | None amended_from: DF.Link | None
base_operating_cost: DF.Currency base_operating_cost: DF.Currency
base_raw_material_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 base_total_cost: DF.Currency
bom_creator: DF.Link | None bom_creator: DF.Link | None
bom_creator_item: DF.Data | None bom_creator_item: DF.Data | None
buying_price_list: DF.Link | None buying_price_list: DF.Link | None
company: DF.Link company: DF.Link
conversion_rate: DF.Float conversion_rate: DF.Float
cost_allocation: DF.Currency
cost_allocation_per: DF.Percent
currency: DF.Link currency: DF.Link
default_source_warehouse: DF.Link | None default_source_warehouse: DF.Link | None
default_target_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"] rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List"]
route: DF.SmallText | None route: DF.SmallText | None
routing: DF.Link | None routing: DF.Link | None
scrap_items: DF.Table[BOMScrapItem] secondary_items: DF.Table[BOMSecondaryItem]
scrap_material_cost: DF.Currency secondary_items_cost: DF.Currency
set_rate_of_sub_assembly_item_based_on_bom: DF.Check set_rate_of_sub_assembly_item_based_on_bom: DF.Check
show_in_website: DF.Check show_in_website: DF.Check
show_items: DF.Check show_items: DF.Check
@@ -284,7 +286,7 @@ class BOM(WebsiteGenerator):
self.set_plc_conversion_rate() self.set_plc_conversion_rate()
self.validate_uom_is_interger() self.validate_uom_is_interger()
self.set_bom_material_details() self.set_bom_material_details()
self.set_bom_scrap_items_detail() self.set_secondary_items_details()
self.validate_materials() self.validate_materials()
self.validate_transfer_against() self.validate_transfer_against()
self.set_routing_operations() self.set_routing_operations()
@@ -294,9 +296,12 @@ class BOM(WebsiteGenerator):
self.update_stock_qty() self.update_stock_qty()
self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False) self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False)
self.set_process_loss_qty() self.set_process_loss_qty()
self.validate_scrap_items() self.validate_uoms()
self.set_default_uom() self.set_default_uom()
self.validate_semi_finished_goods() self.validate_semi_finished_goods()
self.validate_secondary_items()
self.set_fg_cost_allocation()
self.validate_total_cost_allocation()
if self.docstatus == 1: if self.docstatus == 1:
self.validate_raw_materials_of_operation() 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): def validate_raw_materials_of_operation(self):
if not self.track_semi_finished_goods or not self.operations: if not self.track_semi_finished_goods or not self.operations:
return return
@@ -401,6 +422,24 @@ class BOM(WebsiteGenerator):
doc = frappe.get_doc("BOM Creator", self.bom_creator) doc = frappe.get_doc("BOM Creator", self.bom_creator)
doc.set_status(save=True) 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): def on_update_after_submit(self):
self.validate_bom_links() self.validate_bom_links()
self.manage_default_bom() self.manage_default_bom()
@@ -462,6 +501,7 @@ class BOM(WebsiteGenerator):
"conversion_factor": item.conversion_factor, "conversion_factor": item.conversion_factor,
"sourced_by_supplier": item.sourced_by_supplier, "sourced_by_supplier": item.sourced_by_supplier,
"do_not_explode": item.do_not_explode, "do_not_explode": item.do_not_explode,
"fetch_rate": True,
} }
) )
@@ -469,13 +509,13 @@ class BOM(WebsiteGenerator):
if not item.get(r): if not item.get(r):
item.set(r, ret[r]) item.set(r, ret[r])
def set_bom_scrap_items_detail(self): def set_secondary_items_details(self):
for item in self.get("scrap_items"): for item in self.get("secondary_items"):
args = { args = {
"item_code": item.item_code, "item_code": item.item_code,
"company": self.company, "company": self.company,
"scrap_items": True, "uom": item.uom,
"bom_no": "", "fetch_rate": False,
} }
ret = self.get_bom_material_detail(args) ret = self.get_bom_material_detail(args)
for key, value in ret.items(): for key, value in ret.items():
@@ -495,7 +535,7 @@ class BOM(WebsiteGenerator):
item = self.get_item_det(args["item_code"]) 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"] = ( args["transfer_for_manufacture"] = (
cstr(args.get("include_item_in_manufacturing", "")) cstr(args.get("include_item_in_manufacturing", ""))
or item or item
@@ -504,7 +544,7 @@ class BOM(WebsiteGenerator):
) )
args.update(item) args.update(item)
rate = self.get_rm_rate(args) rate = self.get_rm_rate(args) if args.get("fetch_rate") else 0
ret_item = { ret_item = {
"item_name": item and args["item_name"] or "", "item_name": item and args["item_name"] or "",
"description": item and args["description"] or "", "description": item and args["description"] or "",
@@ -546,9 +586,7 @@ class BOM(WebsiteGenerator):
if not self.rm_cost_as_per: if not self.rm_cost_as_per:
self.rm_cost_as_per = "Valuation Rate" self.rm_cost_as_per = "Valuation Rate"
if arg.get("scrap_items"): if arg:
rate = get_valuation_rate(arg)
elif arg:
# Customer Provided parts and Supplier sourced parts will have zero rate # 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( if not frappe.db.get_value("Item", arg["item_code"], "is_customer_provided_item") and not arg.get(
"sourced_by_supplier" "sourced_by_supplier"
@@ -688,7 +726,7 @@ class BOM(WebsiteGenerator):
) )
def update_stock_qty(self): 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: if not m.conversion_factor:
m.conversion_factor = flt(get_conversion_factor(m.item_code, m.uom)["conversion_factor"]) m.conversion_factor = flt(get_conversion_factor(m.item_code, m.uom)["conversion_factor"])
if m.uom and m.qty: if m.uom and m.qty:
@@ -889,16 +927,16 @@ class BOM(WebsiteGenerator):
"""Calculate bom totals""" """Calculate bom totals"""
self.calculate_op_cost(update_hour_rate) self.calculate_op_cost(update_hour_rate)
self.calculate_rm_cost(save=save_updates) 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: if save_updates:
# not via doc event, table is not regenerated and needs updation # not via doc event, table is not regenerated and needs updation
self.calculate_exploded_cost() self.calculate_exploded_cost()
old_cost = self.total_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_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: if self.total_cost != old_cost:
@@ -997,29 +1035,24 @@ class BOM(WebsiteGenerator):
self.raw_material_cost = total_rm_cost self.raw_material_cost = total_rm_cost
self.base_raw_material_cost = base_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""" """Fetch RM rate as per today's valuation rate and calculate totals"""
total_sm_cost = 0 total_sm_cost = 0
base_total_sm_cost = 0 base_total_sm_cost = 0
precision = self.precision("raw_material_cost")
for d in self.get("scrap_items"): for d in self.get("secondary_items"):
d.base_rate = flt(d.rate, d.precision("rate")) * flt( if not d.is_legacy:
self.conversion_rate, self.precision("conversion_rate") d.cost = flt(self.raw_material_cost * (d.cost_allocation_per / 100), precision)
) d.base_cost = flt(d.cost * self.conversion_rate, precision)
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()
self.scrap_material_cost = total_sm_cost total_sm_cost += d.cost
self.base_scrap_material_cost = base_total_sm_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): def calculate_exploded_cost(self):
"Set exploded row cost from it's parent BOM." "Set exploded row cost from it's parent BOM."
@@ -1221,16 +1254,29 @@ class BOM(WebsiteGenerator):
if self.process_loss_percentage: if self.process_loss_percentage:
self.process_loss_qty = flt(self.quantity) * flt(self.process_loss_percentage) / 100 self.process_loss_qty = flt(self.quantity) * flt(self.process_loss_percentage) / 100
def validate_scrap_items(self): for item in self.secondary_items:
must_be_whole_number = frappe.get_value("UOM", self.uom, "must_be_whole_number") 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")) 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: if process_loss_qty and must_be_whole_number and 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." 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")) 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): def get_bom_item_rate(args, bom_doc):
if bom_doc.rm_cost_as_per == "Valuation Rate": if bom_doc.rm_cost_as_per == "Valuation Rate":
@@ -1332,7 +1378,7 @@ def get_bom_items_as_dict(
company, company,
qty=1, qty=1,
fetch_exploded=1, fetch_exploded=1,
fetch_scrap_items=0, fetch_secondary_items=0,
include_non_stock_items=False, include_non_stock_items=False,
fetch_qty_in_stock_uom=True, fetch_qty_in_stock_uom=True,
): ):
@@ -1343,7 +1389,7 @@ def get_bom_items_as_dict(
fetch_exploded = 0 fetch_exploded = 0
group_by_cond = "group by item_code, operation_row_id, stock_uom" group_by_cond = "group by item_code, operation_row_id, stock_uom"
if fetch_scrap_items: if fetch_secondary_items:
fetch_exploded = 0 fetch_exploded = 0
group_by_cond = "group by item_code" 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, sum(bom_item.{qty_field}/ifnull(bom.quantity, 1)) * %(qty)s as qty,
item.image, item.image,
bom.project, 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.stock_uom,
item.item_group, item.item_group,
item.allow_alternative_item, item.allow_alternative_item,
@@ -1388,17 +1432,18 @@ def get_bom_items_as_dict(
group_by_cond=group_by_cond, group_by_cond=group_by_cond,
select_columns=""", bom_item.source_warehouse, bom_item.operation, 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, 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""", (Select idx from `tabBOM Item` where item_code = bom_item.item_code and parent = %(parent)s limit 1) as idx""",
) )
items = frappe.db.sql( items = frappe.db.sql(
query, {"parent": bom, "qty": qty, "bom": bom, "company": company}, as_dict=True query, {"parent": bom, "qty": qty, "bom": bom, "company": company}, as_dict=True
) )
elif fetch_scrap_items: elif fetch_secondary_items:
query = query.format( query = query.format(
table="BOM Scrap Item", table="BOM Secondary Item",
where_conditions=")", 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, is_stock_item=is_stock_item,
qty_field="stock_qty", qty_field="stock_qty",
group_by_cond=group_by_cond, group_by_cond=group_by_cond,
@@ -1411,8 +1456,9 @@ def get_bom_items_as_dict(
where_conditions="or bom_item.is_phantom_item)", where_conditions="or bom_item.is_phantom_item)",
is_stock_item=is_stock_item, is_stock_item=is_stock_item,
qty_field="stock_qty" if fetch_qty_in_stock_uom else "qty", 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, 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 """, 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, group_by_cond=group_by_cond,
) )
@@ -1432,7 +1478,7 @@ def get_bom_items_as_dict(
company, company,
qty=item.get("qty"), qty=item.get("qty"),
fetch_exploded=fetch_exploded, fetch_exploded=fetch_exploded,
fetch_scrap_items=fetch_scrap_items, fetch_secondary_items=fetch_secondary_items,
include_non_stock_items=include_non_stock_items, include_non_stock_items=include_non_stock_items,
fetch_qty_in_stock_uom=fetch_qty_in_stock_uom, 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: for d in bom.items:
if d.item_code.lower() == item.lower(): if d.item_code.lower() == item.lower():
rm_item_exists = True rm_item_exists = True
for d in bom.scrap_items: for d in bom.secondary_items:
if d.item_code.lower() == item.lower(): if d.item_code.lower() == item.lower():
rm_item_exists = True rm_item_exists = True
if ( if (
@@ -1773,7 +1819,7 @@ def get_bom_diff(bom1, bom2):
identifiers = { identifiers = {
"operations": "operation", "operations": "operation",
"items": "item_code", "items": "item_code",
"scrap_items": "item_code", "secondary_items": "item_code",
"exploded_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 return op_cost
def get_scrap_items_from_sub_assemblies(bom_no, company, qty, scrap_items=None): def get_secondary_items_from_sub_assemblies(bom_no, company, qty, secondary_items=None):
if not scrap_items: if not secondary_items:
scrap_items = {} secondary_items = {}
bom_items = frappe.get_all( bom_items = frappe.get_all(
"BOM Item", "BOM Item",
@@ -1935,9 +1981,9 @@ def get_scrap_items_from_sub_assemblies(bom_no, company, qty, scrap_items=None):
continue continue
qty = flt(row.qty) * flt(qty) 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) items = get_bom_items_as_dict(row.bom_no, company, qty=qty, fetch_exploded=0, fetch_secondary_items=1)
scrap_items.update(items) 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: if scrap_qty:
bom_doc.append( bom_doc.append(
"scrap_items", "secondary_items",
{ {
"item_code": fg_item.item_code, "item_code": fg_item.item_code,
"qty": scrap_qty, "qty": scrap_qty,

View File

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

View File

@@ -356,7 +356,6 @@ class BOMCreator(Document):
{ {
"bom_no": bom_no, "bom_no": bom_no,
"allow_alternative_item": 1, "allow_alternative_item": 1,
"allow_scrap_items": not item.get("is_phantom_item"),
"include_item_in_manufacturing": 1, "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 # For license information, please see license.txt
# import frappe
from frappe.model.document import Document from frappe.model.document import Document
class BOMScrapItem(Document): class BOMSecondaryItem(Document):
# begin: auto-generated types # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.
@@ -14,17 +14,26 @@ class BOMScrapItem(Document):
if TYPE_CHECKING: if TYPE_CHECKING:
from frappe.types import DF from frappe.types import DF
amount: DF.Currency base_cost: DF.Currency
base_amount: DF.Currency conversion_factor: DF.Float
base_rate: DF.Currency 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_code: DF.Link
item_name: DF.Data | None item_name: DF.Data | None
parent: DF.Data parent: DF.Data
parentfield: DF.Data parentfield: DF.Data
parenttype: DF.Data parenttype: DF.Data
process_loss_per: DF.Percent
process_loss_qty: DF.Float
qty: DF.Float
rate: DF.Currency rate: DF.Currency
stock_qty: DF.Float stock_qty: DF.Float
stock_uom: DF.Link | None stock_uom: DF.Link | None
type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
uom: DF.Link
# end: auto-generated types # end: auto-generated types
pass 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 { return {
filters: { filters: {
disabled: 0, disabled: 0,
@@ -104,7 +104,7 @@ frappe.ui.form.on("Job Card", {
frm.doc.docstatus === 1 && frm.doc.docstatus === 1 &&
!frm.doc.is_subcontracted && !frm.doc.is_subcontracted &&
(frm.doc.skip_material_transfer || frm.doc.transferred_qty > 0) && (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"), () => { frm.add_custom_button(__("Make Stock Entry"), () => {
frappe.confirm( frappe.confirm(
@@ -278,8 +278,6 @@ frappe.ui.form.on("Job Card", {
frm.trigger("complete_job_card"); frm.trigger("complete_job_card");
}); });
} }
frm.trigger("make_dashboard");
} }
} }

View File

@@ -59,8 +59,8 @@
"time_logs", "time_logs",
"section_break_21", "section_break_21",
"sub_operations", "sub_operations",
"scrap_items_section", "secondary_items_section",
"scrap_items", "secondary_items",
"corrective_operation_section", "corrective_operation_section",
"for_job_card", "for_job_card",
"is_corrective_job_card", "is_corrective_job_card",
@@ -406,20 +406,6 @@
"options": "Batch", "options": "Batch",
"read_only": 1 "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", "fetch_from": "operation.quality_inspection_template",
"fieldname": "quality_inspection_template", "fieldname": "quality_inspection_template",
@@ -623,12 +609,26 @@
{ {
"fieldname": "column_break_xhzg", "fieldname": "column_break_xhzg",
"fieldtype": "Column Break" "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, "grid_page_length": 50,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-02-06 18:27:03.178783", "modified": "2026-02-26 15:13:56.767070",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Job Card", "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 ( from erpnext.manufacturing.doctype.job_card_scheduled_time.job_card_scheduled_time import (
JobCardScheduledTime, 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 from erpnext.manufacturing.doctype.job_card_time_log.job_card_time_log import JobCardTimeLog
actual_end_date: DF.Datetime | None actual_end_date: DF.Datetime | None
@@ -110,7 +112,7 @@ class JobCard(Document):
remarks: DF.SmallText | None remarks: DF.SmallText | None
requested_qty: DF.Float requested_qty: DF.Float
scheduled_time_logs: DF.Table[JobCardScheduledTime] scheduled_time_logs: DF.Table[JobCardScheduledTime]
scrap_items: DF.Table[JobCardScrapItem] secondary_items: DF.Table[JobCardSecondaryItem]
semi_fg_bom: DF.Link | None semi_fg_bom: DF.Link | None
sequence_id: DF.Int sequence_id: DF.Int
serial_and_batch_bundle: DF.Link | None serial_and_batch_bundle: DF.Link | None
@@ -199,6 +201,7 @@ class JobCard(Document):
def set_manufactured_qty(self): def set_manufactured_qty(self):
table_name = "Stock Entry" table_name = "Stock Entry"
child_name = "Stock Entry Detail"
if self.is_subcontracted: if self.is_subcontracted:
table_name = "Subcontracting Receipt Item" table_name = "Subcontracting Receipt Item"
@@ -208,8 +211,13 @@ class JobCard(Document):
if self.is_subcontracted: if self.is_subcontracted:
query = query.select(Sum(table.qty)) query = query.select(Sum(table.qty))
else: else:
query = query.select(Sum(table.fg_completed_qty)) child = frappe.qb.DocType(child_name)
query = query.where(table.purpose == "Manufacture") 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 qty = query.run()[0][0] or 0.0
self.manufactured_qty = flt(qty) self.manufactured_qty = flt(qty)
@@ -267,25 +275,35 @@ class JobCard(Document):
row.sub_operation = row.operation row.sub_operation = row.operation
self.append("sub_operations", row) self.append("sub_operations", row)
def set_scrap_items(self): def set_secondary_items(self):
if not self.semi_fg_bom: if not self.semi_fg_bom and not self.bom_no:
return return
items_dict = get_bom_items_as_dict( 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(): for item_code, values in items_dict.items():
values = frappe._dict(values) 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( if not values.is_legacy:
"scrap_items", secondary_item["stock_qty"] -= flt(
{ secondary_item["stock_qty"] * (values.process_loss_per / 100),
"item_code": item_code, self.precision("for_quantity"),
"stock_qty": values.qty, )
"item_name": values.item_name,
"stock_uom": values.stock_uom, self.append("secondary_items", secondary_item)
},
)
def validate_time_logs(self, save=False): def validate_time_logs(self, save=False):
self.total_time_in_mins = 0.0 self.total_time_in_mins = 0.0
@@ -1181,7 +1199,7 @@ class JobCard(Document):
def set_status(self, update_status=False): def set_status(self, update_status=False):
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0] self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
if self.finished_good and self.docstatus == 1: 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" self.status = "Completed"
elif self.transferred_qty > 0 or self.skip_material_transfer: elif self.transferred_qty > 0 or self.skip_material_transfer:
self.status = "Work In Progress" self.status = "Work In Progress"
@@ -1456,12 +1474,24 @@ class JobCard(Document):
) )
@frappe.whitelist() @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 from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry
ste = ManufactureEntry( ste = ManufactureEntry(
{ {
"for_quantity": self.for_quantity - self.manufactured_qty, "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, "job_card": self.name,
"skip_material_transfer": self.skip_material_transfer, "skip_material_transfer": self.skip_material_transfer,
"backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, "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) wo_doc = frappe.get_doc("Work Order", self.work_order)
add_additional_cost(ste.stock_entry, wo_doc, self) 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: 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 row.t_warehouse = self.target_warehouse
if auto_submit: 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)) s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6))
self.assertEqual(s.additional_costs[0].amount, 8) 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(): def create_bom_with_multiple_operations():
"Create a BOM with multiple operations and Material Transfer against Job Card" "Create a BOM with multiple operations and Material Transfer against Job Card"

View File

@@ -5,10 +5,12 @@
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"type",
"description",
"column_break_3",
"item_code", "item_code",
"item_name", "item_name",
"column_break_3", "bom_secondary_item",
"description",
"quantity_and_rate", "quantity_and_rate",
"stock_qty", "stock_qty",
"column_break_6", "column_break_6",
@@ -19,7 +21,7 @@
"fieldname": "item_code", "fieldname": "item_code",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Scrap Item Code", "label": "Secondary Item Code",
"options": "Item", "options": "Item",
"reqd": 1 "reqd": 1
}, },
@@ -28,7 +30,7 @@
"fieldname": "item_name", "fieldname": "item_name",
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"label": "Scrap Item Name" "label": "Secondary Item Name"
}, },
{ {
"fieldname": "column_break_3", "fieldname": "column_break_3",
@@ -65,18 +67,34 @@
"label": "Stock UOM", "label": "Stock UOM",
"options": "UOM", "options": "UOM",
"read_only": 1 "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, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-07-29 13:09:57.323835", "modified": "2026-03-06 13:51:00.492621",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Job Card Scrap Item", "name": "Job Card Secondary Item",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"quick_entry": 1, "quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],

View File

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

View File

@@ -36,7 +36,7 @@
"capacity_planning_for_days", "capacity_planning_for_days",
"mins_between_operations", "mins_between_operations",
"other_settings_section", "other_settings_section",
"set_op_cost_and_scrap_from_sub_assemblies", "set_op_cost_and_secondary_items_from_sub_assemblies",
"column_break_23", "column_break_23",
"make_serial_no_batch_from_work_order" "make_serial_no_batch_from_work_order"
], ],
@@ -202,13 +202,6 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Validate Components and Quantities Per BOM" "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", "default": "0",
"description": "Enabling this checkbox will force each Job Card Time Log to have From Time and To Time", "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", "fieldname": "allow_editing_of_items_and_quantities_in_work_order",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow Editing of Items and Quantities in Work Order" "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, "hide_toolbar": 0,
@@ -244,7 +244,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-03-16 13:28:20.714576", "modified": "2026-03-20 13:28:20.714576",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Manufacturing Settings", "name": "Manufacturing Settings",

View File

@@ -32,7 +32,7 @@ class ManufacturingSettings(Document):
mins_between_operations: DF.Int mins_between_operations: DF.Int
overproduction_percentage_for_sales_order: DF.Percent overproduction_percentage_for_sales_order: DF.Percent
overproduction_percentage_for_work_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 transfer_extra_materials_percentage: DF.Percent
update_bom_costs_automatically: DF.Check update_bom_costs_automatically: DF.Check
validate_components_quantities_per_bom: 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", "company": args.company or "_Test Company",
"routing": args.routing, "routing": args.routing,
"with_operations": args.with_operations or 0, "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: if not args.do_not_save:
bom.insert(ignore_permissions=True) 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) 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) wo_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
# add raw materials to stores # 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 "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) self.assertEqual(wo_order_details.produced_qty, 2)
for item in s.items: 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(wo_order_details.scrap_warehouse, item.t_warehouse)
self.assertEqual( 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): def test_allow_overproduction(self):
@@ -1015,7 +1015,7 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(wo.status, "Completed") self.assertEqual(wo.status, "Completed")
@timeout(seconds=60) @timeout(seconds=60)
def test_job_card_scrap_item(self): def test_job_card_secondary_item(self):
items = [ items = [
"Test FG Item for Scrap Item Test", "Test FG Item for Scrap Item Test",
"Test RM Item 1 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)) stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
for row in stock_entry.items: 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) self.assertEqual(row.qty, 1)
# Partial Job Card 1 with qty 10 # 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)) stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
for row in stock_entry.items: 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) self.assertEqual(row.qty, 2)
# Partial Job Card 2 with qty 10 # Partial Job Card 2 with qty 10
@@ -2134,10 +2134,12 @@ class TestWorkOrder(ERPNextTestSuite):
for row in se_doc.additional_costs: for row in se_doc.additional_costs:
self.assertEqual(row.expense_account, operating_cost_account) 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 # 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 = { items = {
"Test Final FG Item": 0, "Test Final FG Item": 0,
@@ -2169,16 +2171,20 @@ class TestWorkOrder(ERPNextTestSuite):
se_doc.save() se_doc.save()
self.assertTrue(se_doc.additional_costs) self.assertTrue(se_doc.additional_costs)
scrap_items = [] secondary_items = []
for item in se_doc.items: for item in se_doc.items:
if item.is_scrap_item: if item.type or item.is_legacy_scrap_item:
scrap_items.append(item.item_code) 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: for row in se_doc.additional_costs:
self.assertEqual(row.amount, 3000) 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( @ERPNextTestSuite.change_settings(
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1} "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, 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() bom.submit()
@@ -3964,7 +3970,7 @@ def prepare_boms_for_sub_assembly_test():
do_not_submit=True, 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() 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") employee = frappe.db.get_value("Employee", {"status": "Active"}, "name")
job_card_doc = frappe.get_doc("Job Card", job_card) job_card_doc = frappe.get_doc("Job Card", job_card)
job_card_doc.set( 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 1 for Scrap Item Test", "stock_qty": 2},
{"item_code": "Test RM Item 2 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() job_card_doc.submit()
def get_scrap_item_details(bom_no): def get_secondary_item_details(bom_no):
scrap_items = {} secondary_items = {}
for item in frappe.db.sql( 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""", where parent = %s""",
bom_no, bom_no,
as_dict=1, 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): def allow_overproduction(fieldname, percentage):

View File

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

View File

@@ -2356,7 +2356,7 @@ def check_if_scrap_warehouse_mandatory(bom_no):
if bom_no: if bom_no:
bom = frappe.get_doc("BOM", 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 res["set_scrap_wh_mandatory"] = True
return res return res
@@ -2420,6 +2420,7 @@ def make_stock_entry(
stock_entry.set_stock_entry_type() stock_entry.set_stock_entry_type()
stock_entry.is_additional_transfer_entry = is_additional_transfer_entry stock_entry.is_additional_transfer_entry = is_additional_transfer_entry
stock_entry.get_items() stock_entry.get_items()
stock_entry.set_secondary_items_from_job_card()
if purpose != "Disassemble": if purpose != "Disassemble":
stock_entry.set_serial_no_batch_for_finished_good() 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() @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): if isinstance(operations, str):
operations = json.loads(operations) operations = json.loads(operations)
work_order = frappe.get_doc("Work Order", work_order) work_order = frappe.get_doc("Work Order", work_order)
for row in operations: for row in operations:
row = frappe._dict(row) 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) validate_operation_data(row)
qty = row.get("qty") qty = row.get("qty")
@@ -2495,7 +2496,7 @@ def make_job_card(work_order, operations):
create_job_card(work_order, row, auto_create=True) 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: for row in work_order.operations:
if row.name == name: if row.name == name:
return { return {
@@ -2505,7 +2506,7 @@ def get_operation_details(name, work_order):
"fg_warehouse": row.fg_warehouse, "fg_warehouse": row.fg_warehouse,
"wip_warehouse": row.wip_warehouse, "wip_warehouse": row.wip_warehouse,
"finished_good": row.finished_good, "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"), "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 work_order.transfer_material_against == "Job Card" and not work_order.skip_transfer
): ):
doc.get_required_items() 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: if auto_create:
doc.flags.ignore_mandatory = True 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.enable_serial_batch_setting
erpnext.patches.v16_0.update_requested_qty_packed_item erpnext.patches.v16_0.update_requested_qty_packed_item
erpnext.patches.v16_0.remove_payables_receivables_workspace 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_operating_cost",
"base_raw_material_cost", "base_raw_material_cost",
"base_total_cost", "base_total_cost",
"base_scrap_material_cost", "base_secondary_items_cost",
"base_totals_section", "base_totals_section",
], ],
company_currency company_currency
@@ -1873,7 +1873,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
"paid_amount", "paid_amount",
"write_off_amount", "write_off_amount",
"operating_cost", "operating_cost",
"scrap_material_cost", "secondary_items_cost",
"raw_material_cost", "raw_material_cost",
"total_cost", "total_cost",
"totals_section", "totals_section",
@@ -1919,7 +1919,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
"base_operating_cost", "base_operating_cost",
"base_raw_material_cost", "base_raw_material_cost",
"base_total_cost", "base_total_cost",
"base_scrap_material_cost", "base_secondary_items_cost",
"base_rounding_adjustment", "base_rounding_adjustment",
], ],
this.frm.doc.currency != company_currency 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) { if (this.frm.doc.secondary_items && this.frm.doc.secondary_items.length > 0) {
this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "scrap_items"); this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "secondary_items");
this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "scrap_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) { $.each(["base_rate", "base_amount"], function (i, fname) {
if (frappe.meta.get_docfield(item_grid.doctype, fname)) if (frappe.meta.get_docfield(item_grid.doctype, fname))
item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency); item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency);

View File

@@ -49,7 +49,7 @@
"section_break_zwh6", "section_break_zwh6",
"allow_delivery_of_overproduced_qty", "allow_delivery_of_overproduced_qty",
"column_break_mla9", "column_break_mla9",
"deliver_scrap_items" "deliver_secondary_items"
], ],
"fields": [ "fields": [
{ {
@@ -260,13 +260,6 @@
"fieldname": "column_break_mla9", "fieldname": "column_break_mla9",
"fieldtype": "Column Break" "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", "fieldname": "item_price_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
@@ -320,6 +313,13 @@
"fieldname": "enable_utm", "fieldname": "enable_utm",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Enable UTM" "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, "grid_page_length": 50,

View File

@@ -41,7 +41,7 @@ class SellingSettings(Document):
blanket_order_allowance: DF.Float blanket_order_allowance: DF.Float
cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"] cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"]
customer_group: DF.Link | None customer_group: DF.Link | None
deliver_scrap_items: DF.Check deliver_secondary_items: DF.Check
dn_required: DF.Literal["No", "Yes"] dn_required: DF.Literal["No", "Yes"]
dont_reserve_sales_order_qty_on_sales_return: DF.Check dont_reserve_sales_order_qty_on_sales_return: DF.Check
editable_bundle_item_rates: 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) boms = frappe.db.sql_list("select name from tabBOM where company=%s", self.name)
if boms: if boms:
frappe.db.sql("delete from tabBOM where company=%s", self.name) 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( frappe.db.sql(
"delete from `tab{}` where parent in ({})".format(dt, ", ".join(["%s"] * len(boms))), "delete from `tab{}` where parent in ({})".format(dt, ", ".join(["%s"] * len(boms))),
tuple(boms), tuple(boms),

View File

@@ -1334,13 +1334,13 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
} }
fg_completed_qty() { fg_completed_qty() {
this.get_items(); if (!this.frm.doc.job_card) {
this.get_items();
}
} }
get_items() { get_items() {
var me = this; 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 (this.frm.doc.work_order || this.frm.doc.bom_no) {
// if work order / bom is mentioned, get items // if work order / bom is mentioned, get items

View File

@@ -31,7 +31,7 @@ from erpnext.manufacturing.doctype.bom.bom import (
add_additional_cost, add_additional_cost,
get_bom_items_as_dict, get_bom_items_as_dict,
get_op_cost_from_sub_assemblies, get_op_cost_from_sub_assemblies,
get_scrap_items_from_sub_assemblies, get_secondary_items_from_sub_assemblies,
validate_bom_no, validate_bom_no,
) )
from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.brand.brand import get_brand_defaults
@@ -245,7 +245,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.validate_company_in_accounting_dimension() self.validate_company_in_accounting_dimension()
if self.purpose in ("Manufacture", "Repack"): if self.purpose in ("Manufacture", "Repack"):
self.mark_finished_and_scrap_items() self.mark_finished_and_secondary_items()
if not self.job_card: if not self.job_card:
self.validate_finished_goods() self.validate_finished_goods()
else: else:
@@ -272,7 +272,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.validate_component_and_quantities() self.validate_component_and_quantities()
if self.get("purpose") != "Manufacture": 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 # in Manufacture Entry
self.reset_default_field_value("from_warehouse", "items", "s_warehouse") self.reset_default_field_value("from_warehouse", "items", "s_warehouse")
self.reset_default_field_value("to_warehouse", "items", "t_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") item.expense_account = frappe.get_value("Company", self.company, "default_expense_account")
def validate_fg_completed_qty(self): def validate_fg_completed_qty(self):
if self.purpose != "Manufacture": if self.purpose != "Manufacture" or not self.from_bom:
return return
fg_qty = defaultdict(float) fg_qty = defaultdict(float)
@@ -789,7 +789,7 @@ class StockEntry(StockController, SubcontractingInwardController):
if self.purpose == "Manufacture": if self.purpose == "Manufacture":
if has_bom: 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 d.s_warehouse = None
if not d.t_warehouse: if not d.t_warehouse:
frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx)) 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): 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 # Set rate for outgoing items
outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate) 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 = [] items = []
# Set basic rate for incoming items # Set basic rate for incoming items
@@ -1111,11 +1110,19 @@ class StockEntry(StockController, SubcontractingInwardController):
elif d.is_finished_item: elif d.is_finished_item:
if self.purpose == "Manufacture": if self.purpose == "Manufacture":
d.basic_rate = self.get_basic_rate_for_manufactured_item( 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": elif self.purpose == "Repack":
d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost) 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 not d.basic_rate and not d.allow_zero_valuation_rate:
if self.is_new(): if self.is_new():
raise_error_if_no_rate = False 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: def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float:
settings = frappe.get_single("Manufacturing Settings") 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.material_consumption:
if settings.get_rm_cost_from_consumption_entry and self.work_order: 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: 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( label = frappe.get_meta(settings.doctype).get_label(
"get_rm_cost_from_consumption_entry" "get_rm_cost_from_consumption_entry"
) )
@@ -1614,7 +1621,7 @@ class StockEntry(StockController, SubcontractingInwardController):
order, order,
) )
def mark_finished_and_scrap_items(self): def mark_finished_and_secondary_items(self):
if self.purpose != "Repack" and any( if self.purpose != "Repack" and any(
[d.item_code for d in self.items if (d.is_finished_item and d.t_warehouse)] [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 d.t_warehouse and not d.s_warehouse:
if self.purpose == "Repack" or d.item_code == finished_item: if self.purpose == "Repack" or d.item_code == finished_item:
d.is_finished_item = 1 d.is_finished_item = 1
else:
d.is_scrap_item = 1
else: else:
d.is_finished_item = 0 d.is_finished_item = 0
d.is_scrap_item = 0 d.type = ""
def get_finished_item(self): def get_finished_item(self):
finished_item = None finished_item = None
@@ -2434,7 +2439,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.load_items_from_bom() self.load_items_from_bom()
self.set_serial_batch_from_reserved_entry() self.set_serial_batch_from_reserved_entry()
self.set_scrap_items() self.set_secondary_items()
self.set_actual_qty() self.set_actual_qty()
self.validate_customer_provided_item() self.validate_customer_provided_item()
self.calculate_rate_and_amount(raise_error_if_no_rate=False) 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) return query.run(as_dict=True)
def set_scrap_items(self): def set_secondary_items(self):
if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]: if self.purpose in ["Manufacture", "Repack"]:
scrap_item_dict = self.get_bom_scrap_material(self.fg_completed_qty) secondary_items_dict = self.get_secondary_items(self.fg_completed_qty)
for item in scrap_item_dict.values(): for item in secondary_items_dict.values():
if self.pro_doc and self.pro_doc.scrap_warehouse: if self.pro_doc and item.type:
item["to_warehouse"] = self.pro_doc.scrap_warehouse 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): def set_process_loss_qty(self):
if self.purpose not in ("Manufacture", "Repack"): if self.purpose not in ("Manufacture", "Repack"):
@@ -2600,7 +2612,7 @@ class StockEntry(StockController, SubcontractingInwardController):
fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}], 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 process_loss_qty = data[0].process_loss_qty
if flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision): if flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision):
self.process_loss_qty = 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: if not self.pro_doc:
self.pro_doc = frappe.get_doc("Work Order", self.work_order) 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 self.bom_no = self.pro_doc.bom_no
else: else:
# invalid work order # invalid work order
@@ -2774,54 +2786,59 @@ class StockEntry(StockController, SubcontractingInwardController):
return item_dict 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 from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
if ( 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 self.work_order
and frappe.get_cached_value("Work Order", self.work_order, "use_multi_level_bom") 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: else:
# item dict = { item_code: {qty, description, stock_uom} } # item dict = { item_code: {qty, description, stock_uom} }
item_dict = ( item_dict = (
get_bom_items_as_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 {} or {}
) )
for item in item_dict.values(): for item in item_dict.values():
item.from_warehouse = "" 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 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"): if not hasattr(self, "pro_doc"):
self.pro_doc = None self.pro_doc = None
@@ -2832,70 +2849,78 @@ class StockEntry(StockController, SubcontractingInwardController):
return [] return []
job_card = frappe.qb.DocType("Job Card") 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) frappe.qb.from_(job_card)
.select( .select(
Sum(job_card_scrap_item.stock_qty).as_("stock_qty"), Sum(job_card_secondary_item.stock_qty).as_("stock_qty"),
job_card_scrap_item.item_code, job_card_secondary_item.item_code,
job_card_scrap_item.item_name, job_card_secondary_item.item_name,
job_card_scrap_item.description, job_card_secondary_item.description,
job_card_scrap_item.stock_uom, job_card_secondary_item.stock_uom,
job_card_secondary_item.type,
job_card_secondary_item.bom_secondary_item,
) )
.join(job_card_scrap_item) .join(job_card_secondary_item)
.on(job_card_scrap_item.parent == job_card.name) .on(job_card_secondary_item.parent == job_card.name)
.where( .where(
(job_card_scrap_item.item_code.isnotnull()) (job_card_secondary_item.item_code.isnotnull())
& (job_card.work_order == self.work_order) & (job_card.work_order == self.work_order)
& (job_card.docstatus == 1) & (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: 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: if self.job_card:
pending_qty = flt(self.fg_completed_qty) pending_qty = flt(self.fg_completed_qty)
else: else:
pending_qty = flt(self.get_completed_job_card_qty()) - flt(self.pro_doc.produced_qty) pending_qty = flt(self.get_completed_job_card_qty()) - flt(self.pro_doc.produced_qty)
used_scrap_items = self.get_used_scrap_items() used_secondary_items = self.get_used_secondary_items()
for row in scrap_items: for row in other:
row.stock_qty -= flt(used_scrap_items.get(row.item_code)) 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) row.stock_qty = (row.stock_qty) * flt(self.fg_completed_qty) / flt(pending_qty)
if used_scrap_items.get(row.item_code): if used_secondary_items.get(row.item_code):
used_scrap_items[row.item_code] -= row.stock_qty used_secondary_items[row.item_code] -= row.stock_qty
if cint(frappe.get_cached_value("UOM", row.stock_uom, "must_be_whole_number")): if cint(frappe.get_cached_value("UOM", row.stock_uom, "must_be_whole_number")):
row.stock_qty = frappe.utils.ceil(row.stock_qty) row.stock_qty = frappe.utils.ceil(row.stock_qty)
return scrap_items return other
def get_completed_job_card_qty(self): def get_completed_job_card_qty(self):
return flt(min([d.completed_qty for d in self.pro_doc.operations])) return flt(min([d.completed_qty for d in self.pro_doc.operations]))
def get_used_scrap_items(self): def get_used_secondary_items(self):
used_scrap_items = defaultdict(float) used_secondary_items = defaultdict(float)
data = frappe.get_all(
"Stock Entry", StockEntry = frappe.qb.DocType("Stock Entry")
fields=["`tabStock Entry Detail`.`item_code`", "`tabStock Entry Detail`.`qty`"], StockEntryDetail = frappe.qb.DocType("Stock Entry Detail")
filters=[ data = (
["Stock Entry", "work_order", "=", self.work_order], frappe.qb.from_(StockEntry)
["Stock Entry Detail", "is_scrap_item", "=", 1], .inner_join(StockEntryDetail)
["Stock Entry", "docstatus", "=", 1], .on(StockEntryDetail.parent == StockEntry.name)
["Stock Entry", "purpose", "in", ["Repack", "Manufacture"]], .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: 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): def get_unconsumed_raw_materials(self):
wo = frappe.get_doc("Work Order", self.work_order) wo = frappe.get_doc("Work Order", self.work_order)
@@ -3187,7 +3212,12 @@ class StockEntry(StockController, SubcontractingInwardController):
item_row = item_dict[d] item_row = item_dict[d]
child_qty = flt(item_row["qty"], precision) 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"]: if self.purpose not in ["Receive from Customer", "Send to Subcontractor"]:
continue continue
@@ -3205,11 +3235,13 @@ class StockEntry(StockController, SubcontractingInwardController):
item_row, company=self.company item_row, company=self.company
) )
se_child.is_finished_item = item_row.get("is_finished_item", 0) 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.po_detail = item_row.get("po_detail")
se_child.sco_rm_detail = item_row.get("sco_rm_detail") se_child.sco_rm_detail = item_row.get("sco_rm_detail")
se_child.scio_detail = item_row.get("scio_detail") se_child.scio_detail = item_row.get("scio_detail")
se_child.sample_quantity = item_row.get("sample_quantity", 0) 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 [ for field in [
self.subcontract_data.rm_detail_field, self.subcontract_data.rm_detail_field,
@@ -3686,7 +3718,7 @@ def get_operating_cost_per_unit(work_order=None, bom_no=None):
if ( if (
bom_no bom_no
and frappe.db.get_single_value( 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") 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: if d.s_warehouse:
rm_cost += d.amount rm_cost += d.amount
fg_cost = next(filter(lambda x: x.item_code == "_Test FG Item", s.get("items"))).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 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 - scrap_cost, 2)) self.assertEqual(fg_cost, flt(rm_cost - secondary_item_cost, 2))
# When Stock Entry has only FG + Scrap # When Stock Entry has only FG + Scrap
s.items.pop(0) s.items.pop(0)
@@ -989,15 +989,15 @@ class TestStockEntry(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, ste.submit) 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 ( from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as _make_stock_entry, make_stock_entry as _make_stock_entry,
) )
scrap_item = "_Test Scrap Item 1" secondary_item = "_Test Scrap Item 1"
make_item(scrap_item, {"is_stock_item": 1, "is_purchase_item": 0}) 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") production_item = frappe.db.get_value("BOM", bom_name, "item")
work_order = frappe.new_doc("Work Order") work_order = frappe.new_doc("Work Order")
@@ -1027,18 +1027,18 @@ class TestStockEntry(ERPNextTestSuite):
basic_rate=row.basic_rate or 100, basic_rate=row.basic_rate or 100,
) )
if row.is_scrap_item: if row.type or row.is_legacy_scrap_item:
row.item_code = scrap_item row.item_code = secondary_item
row.uom = frappe.db.get_value("Item", scrap_item, "stock_uom") row.uom = frappe.db.get_value("Item", secondary_item, "stock_uom")
row.stock_uom = frappe.db.get_value("Item", scrap_item, "stock_uom") row.stock_uom = frappe.db.get_value("Item", secondary_item, "stock_uom")
stock_entry.inspection_required = 1 stock_entry.inspection_required = 1
stock_entry.save() 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: 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( qc = frappe.get_doc(
{ {
"doctype": "Quality Inspection", "doctype": "Quality Inspection",
@@ -1058,7 +1058,7 @@ class TestStockEntry(ERPNextTestSuite):
stock_entry.reload() stock_entry.reload()
stock_entry.submit() stock_entry.submit()
for row in stock_entry.items: 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) self.assertFalse(row.quality_inspection)
else: else:
self.assertTrue(row.quality_inspection) self.assertTrue(row.quality_inspection)
@@ -2464,6 +2464,35 @@ class TestStockEntry(ERPNextTestSuite):
# delete naming rule # delete naming rule
frappe.delete_doc("Document Naming Rule", qc_naming_rule.name) 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): def make_serialized_item(self, **args):
args = frappe._dict(args) args = frappe._dict(args)

View File

@@ -18,7 +18,8 @@
"item_name", "item_name",
"col_break2", "col_break2",
"is_finished_item", "is_finished_item",
"is_scrap_item", "is_legacy_scrap_item",
"type",
"quality_inspection", "quality_inspection",
"subcontracted_item", "subcontracted_item",
"against_fg", "against_fg",
@@ -81,7 +82,8 @@
"putaway_rule", "putaway_rule",
"column_break_51", "column_break_51",
"reference_purchase_receipt", "reference_purchase_receipt",
"job_card_item" "job_card_item",
"bom_secondary_item"
], ],
"fields": [ "fields": [
{ {
@@ -558,12 +560,7 @@
}, },
{ {
"default": "0", "default": "0",
"fieldname": "is_scrap_item", "depends_on": "eval:!doc.is_legacy_scrap_item && !doc.type",
"fieldtype": "Check",
"label": "Is Scrap Item"
},
{
"default": "0",
"fieldname": "is_finished_item", "fieldname": "is_finished_item",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Is Finished Item", "label": "Is Finished Item",
@@ -654,6 +651,28 @@
"no_copy": 1, "no_copy": 1,
"options": "Subcontracting Inward Order Item", "options": "Subcontracting Inward Order Item",
"set_only_once": 1 "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, "grid_page_length": 50,

View File

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

View File

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

View File

@@ -25,7 +25,7 @@
"raw_materials_received_section", "raw_materials_received_section",
"received_items", "received_items",
"scrap_items_generated_section", "scrap_items_generated_section",
"scrap_items", "secondary_items",
"service_items_section", "service_items_section",
"service_items", "service_items",
"tab_other_info", "tab_other_info",
@@ -252,17 +252,10 @@
"reqd": 1 "reqd": 1
}, },
{ {
"depends_on": "scrap_items", "depends_on": "secondary_items",
"fieldname": "scrap_items_generated_section", "fieldname": "scrap_items_generated_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Scrap Items Generated" "label": "Secondary Items Generated"
},
{
"fieldname": "scrap_items",
"fieldtype": "Table",
"label": "Scrap Items",
"no_copy": 1,
"options": "Subcontracting Inward Order Scrap Item"
}, },
{ {
"fieldname": "per_returned", "fieldname": "per_returned",
@@ -300,13 +293,20 @@
"label": "Customer Currency", "label": "Customer Currency",
"options": "Currency", "options": "Currency",
"read_only": 1 "read_only": 1
},
{
"fieldname": "secondary_items",
"fieldtype": "Table",
"label": "Secondary Items",
"no_copy": 1,
"options": "Subcontracting Inward Order Secondary Item"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2025-12-09 15:52:55.781346", "modified": "2026-02-26 17:16:21.697846",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Subcontracting", "module": "Subcontracting",
"name": "Subcontracting Inward Order", "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 ( from erpnext.subcontracting.doctype.subcontracting_inward_order_received_item.subcontracting_inward_order_received_item import (
SubcontractingInwardOrderReceivedItem, SubcontractingInwardOrderReceivedItem,
) )
from erpnext.subcontracting.doctype.subcontracting_inward_order_scrap_item.subcontracting_inward_order_scrap_item import ( from erpnext.subcontracting.doctype.subcontracting_inward_order_secondary_item.subcontracting_inward_order_secondary_item import (
SubcontractingInwardOrderScrapItem, SubcontractingInwardOrderSecondaryItem,
) )
from erpnext.subcontracting.doctype.subcontracting_inward_order_service_item.subcontracting_inward_order_service_item import ( from erpnext.subcontracting.doctype.subcontracting_inward_order_service_item.subcontracting_inward_order_service_item import (
SubcontractingInwardOrderServiceItem, SubcontractingInwardOrderServiceItem,
@@ -48,7 +48,7 @@ class SubcontractingInwardOrder(SubcontractingController):
per_returned: DF.Percent per_returned: DF.Percent
received_items: DF.Table[SubcontractingInwardOrderReceivedItem] received_items: DF.Table[SubcontractingInwardOrderReceivedItem]
sales_order: DF.Link sales_order: DF.Link
scrap_items: DF.Table[SubcontractingInwardOrderScrapItem] secondary_items: DF.Table[SubcontractingInwardOrderSecondaryItem]
service_items: DF.Table[SubcontractingInwardOrderServiceItem] service_items: DF.Table[SubcontractingInwardOrderServiceItem]
set_delivery_warehouse: DF.Link | None set_delivery_warehouse: DF.Link | None
status: DF.Literal[ status: DF.Literal[
@@ -474,23 +474,25 @@ class SubcontractingInwardOrder(SubcontractingController):
stock_entry.add_to_stock_entry_detail(items_dict) stock_entry.add_to_stock_entry_detail(items_dict)
if ( if (
frappe.get_single_value("Selling Settings", "deliver_scrap_items") frappe.get_single_value("Selling Settings", "deliver_secondary_items")
and self.scrap_items and self.secondary_items
and scio_details and scio_details
): ):
scrap_items = [ secondary_items = [
scrap_item for scrap_item in self.scrap_items if scrap_item.reference_name in scio_details secondary_item
for secondary_item in self.secondary_items
if secondary_item.reference_name in scio_details
] ]
for scrap_item in scrap_items: for secondary_item in secondary_items:
qty = scrap_item.produced_qty - scrap_item.delivered_qty qty = secondary_item.produced_qty - secondary_item.delivered_qty
if qty > 0: if qty > 0:
items_dict = { items_dict = {
scrap_item.item_code: { secondary_item.item_code: {
"qty": scrap_item.produced_qty - scrap_item.delivered_qty, "qty": secondary_item.produced_qty - secondary_item.delivered_qty,
"from_warehouse": scrap_item.warehouse, "from_warehouse": secondary_item.warehouse,
"stock_uom": scrap_item.stock_uom, "stock_uom": secondary_item.stock_uom,
"scio_detail": scrap_item.name, "scio_detail": secondary_item.name,
"is_scrap_item": 1, "type": secondary_item.type,
} }
} }

View File

@@ -323,10 +323,12 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite):
delivery.items[0].qty = 6 delivery.items[0].qty = 6
self.assertRaises(frappe.ValidationError, delivery.submit) 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): def test_secondary_items_delivery(self):
new_bom = frappe.copy_doc(frappe.get_doc("BOM", "BOM-Basic FG Item-001")) 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() new_bom.submit()
sc_bom = frappe.get_doc("Subcontracting BOM", "SB-0001") sc_bom = frappe.get_doc("Subcontracting BOM", "SB-0001")
sc_bom.finished_good_bom = new_bom.name 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() frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture")).submit()
scio.reload() 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()) delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery())
self.assertEqual(delivery.items[-1].item_code, "Basic RM 2") 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()) delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery())
self.assertNotEqual(delivery.items[-1].item_code, "Basic RM 2") self.assertNotEqual(delivery.items[-1].item_code, "Basic RM 2")

View File

@@ -6,13 +6,15 @@
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"column_break_rptg",
"type",
"reference_name",
"column_break_jkzt",
"item_code", "item_code",
"fg_item_code", "fg_item_code",
"column_break_hoxe", "column_break_hoxe",
"stock_uom", "stock_uom",
"warehouse", "warehouse",
"column_break_rptg",
"reference_name",
"section_break_gqk9", "section_break_gqk9",
"produced_qty", "produced_qty",
"column_break_n4xc", "column_break_n4xc",
@@ -93,16 +95,29 @@
{ {
"fieldname": "column_break_n4xc", "fieldname": "column_break_n4xc",
"fieldtype": "Column Break" "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, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-10-14 10:28:30.192350", "modified": "2026-02-27 15:15:40.009957",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Subcontracting", "module": "Subcontracting",
"name": "Subcontracting Inward Order Scrap Item", "name": "Subcontracting Inward Order Secondary Item",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"row_format": "Dynamic", "row_format": "Dynamic",

View File

@@ -5,7 +5,7 @@
from frappe.model.document import Document from frappe.model.document import Document
class SubcontractingInwardOrderScrapItem(Document): class SubcontractingInwardOrderSecondaryItem(Document):
# begin: auto-generated types # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.
@@ -23,6 +23,7 @@ class SubcontractingInwardOrderScrapItem(Document):
produced_qty: DF.Float produced_qty: DF.Float
reference_name: DF.Data reference_name: DF.Data
stock_uom: DF.Link stock_uom: DF.Link
type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
warehouse: DF.Link warehouse: DF.Link
# end: auto-generated types # 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 = source_parent.purchase_order
target.purchase_order_item = source.purchase_order_item target.purchase_order_item = source.purchase_order_item
target.qty = items.get(source.name) or (flt(source.qty) - flt(source.received_qty)) 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) target.amount = (flt(source.qty) - flt(source.received_qty)) * flt(source.rate)
items = {item["name"]: item["qty"] for item in items} if items else {} items = {item["name"]: item["qty"] for item in items} if items else {}

View File

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

View File

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

View File

@@ -29,8 +29,8 @@
"col_break_warehouse", "col_break_warehouse",
"supplier_warehouse", "supplier_warehouse",
"items_section", "items_section",
"get_scrap_items",
"items", "items",
"get_secondary_items",
"section_break0", "section_break0",
"total_qty", "total_qty",
"column_break_27", "column_break_27",
@@ -631,13 +631,6 @@
"label": "Edit Posting Date and Time", "label": "Edit Posting Date and Time",
"print_hide": 1 "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", "fieldname": "supplier_delivery_note",
"fieldtype": "Data", "fieldtype": "Data",
@@ -674,12 +667,19 @@
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Connections", "label": "Connections",
"show_dashboard": 1 "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, "in_create": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2025-10-08 21:43:27.065640", "modified": "2026-02-27 17:59:44.107193",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Subcontracting", "module": "Subcontracting",
"name": "Subcontracting Receipt", "name": "Subcontracting Receipt",

View File

@@ -144,12 +144,12 @@ class SubcontractingReceipt(SubcontractingController):
super().validate() super().validate()
if self.is_new() and self.get("_action") == "save" and not frappe.in_test: 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() self.set_missing_values()
if self.get("_action") == "submit": if self.get("_action") == "submit":
self.validate_scrap_items() self.validate_secondary_items()
self.validate_accepted_warehouse() self.validate_accepted_warehouse()
self.validate_rejected_warehouse() self.validate_rejected_warehouse()
@@ -343,39 +343,66 @@ class SubcontractingReceipt(SubcontractingController):
self.update_rate_for_supplied_items() self.update_rate_for_supplied_items()
@frappe.whitelist() @frappe.whitelist()
def get_scrap_items(self, recalculate_rate=False): def get_secondary_items(self, recalculate_rate: bool | None = False):
self.remove_scrap_items() self.remove_secondary_items()
for item in list(self.items): for item in list(self.items):
if item.bom: if item.bom:
bom = frappe.get_doc("BOM", item.bom) bom = frappe.get_doc("BOM", item.bom)
for scrap_item in bom.scrap_items: for secondary_item in bom.secondary_items:
qty = flt(item.qty) * (flt(scrap_item.stock_qty) / flt(bom.quantity)) per_unit = secondary_item.stock_qty / bom.quantity
rate = ( received_qty = flt(item.received_qty * per_unit, item.precision("received_qty"))
get_valuation_rate( qty = flt(
scrap_item.item_code, item.received_qty * (per_unit - (secondary_item.process_loss_qty / bom.quantity)),
self.set_warehouse, item.precision("qty"),
self.doctype,
self.name,
currency=erpnext.get_company_currency(self.company),
company=self.company,
)
or scrap_item.rate
) )
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( self.append(
"items", "items",
{ {
"is_scrap_item": 1, "type": secondary_item.type,
"is_legacy_scrap_item": secondary_item.is_legacy,
"reference_name": item.name, "reference_name": item.name,
"item_code": scrap_item.item_code, "item_code": secondary_item.item_code,
"item_name": scrap_item.item_name, "item_name": secondary_item.item_name,
"qty": qty, "qty": received_qty
"stock_uom": scrap_item.stock_uom, 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, "rate": rate,
"rm_cost_per_qty": 0, "rm_cost_per_qty": 0,
"service_cost_per_qty": 0, "service_cost_per_qty": 0,
"additional_cost_per_qty": 0, "additional_cost_per_qty": 0,
"scrap_cost_per_qty": 0, "secondary_items_cost_per_qty": 0,
"amount": qty * rate, "amount": qty * rate,
"warehouse": self.set_warehouse, "warehouse": self.set_warehouse,
"rejected_warehouse": self.rejected_warehouse, "rejected_warehouse": self.rejected_warehouse,
@@ -386,15 +413,12 @@ class SubcontractingReceipt(SubcontractingController):
self.calculate_additional_costs() self.calculate_additional_costs()
self.calculate_items_qty_and_amount() 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): for item in list(self.items):
if item.is_scrap_item: if item.type or item.is_legacy_scrap_item:
self.remove(item) self.remove(item)
else: else:
item.scrap_cost_per_qty = 0 item.secondary_items_cost_per_qty = 0
if recalculate_rate:
self.calculate_items_qty_and_amount()
@frappe.whitelist() @frappe.whitelist()
def set_missing_values(self): def set_missing_values(self):
@@ -449,30 +473,35 @@ class SubcontractingReceipt(SubcontractingController):
else: else:
rm_cost_map[item.reference_name] = item.amount rm_cost_map[item.reference_name] = item.amount
scrap_cost_map = {} secondary_items_cost_map = {}
for item in self.get("items") or []: for item in self.get("items") or []:
if item.is_scrap_item: if item.type or item.is_legacy_scrap_item:
item.amount = flt(item.qty) * flt(item.rate) 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: if item.reference_name in secondary_items_cost_map:
scrap_cost_map[item.reference_name] += item.amount secondary_items_cost_map[item.reference_name] += item.amount
else: else:
scrap_cost_map[item.reference_name] = item.amount secondary_items_cost_map[item.reference_name] = item.amount
total_qty = total_amount = 0 total_qty = total_amount = 0
for item in self.get("items") or []: 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.qty:
if item.name in rm_cost_map: if item.name in rm_cost_map:
item.rm_supp_cost = rm_cost_map[item.name] 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) rm_cost_map.pop(item.name)
if item.name in scrap_cost_map: if item.name in secondary_items_cost_map:
item.scrap_cost_per_qty = scrap_cost_map[item.name] / item.qty item.secondary_items_cost_per_qty = secondary_items_cost_map[item.name] / item.qty
scrap_cost_map.pop(item.name) secondary_items_cost_map.pop(item.name)
else: else:
item.scrap_cost_per_qty = 0 item.secondary_items_cost_per_qty = 0
lcv_cost_per_qty = 0.0 lcv_cost_per_qty = 0.0
if item.landed_cost_voucher_amount: if item.landed_cost_voucher_amount:
@@ -483,36 +512,44 @@ class SubcontractingReceipt(SubcontractingController):
+ flt(item.service_cost_per_qty) + flt(item.service_cost_per_qty)
+ flt(item.additional_cost_per_qty) + flt(item.additional_cost_per_qty)
+ flt(lcv_cost_per_qty) + flt(lcv_cost_per_qty)
- flt(item.scrap_cost_per_qty)
) )
item.received_qty = flt(item.qty) + flt(item.rejected_qty) if item.bom:
item.amount = flt(item.qty) * flt(item.rate) 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 total_amount += item.amount
else: else:
self.total_qty = total_qty self.total_qty = total_qty
self.total = total_amount self.total = total_amount
def validate_scrap_items(self): def validate_secondary_items(self):
for item in self.items: for item in self.items:
if item.is_scrap_item: if item.type or item.is_legacy_scrap_item:
if not item.qty: if not item.qty:
frappe.throw( 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: if item.rejected_qty:
frappe.throw( 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) item.idx, frappe.bold(item.item_code)
), ),
) )
if not item.reference_name: if not item.reference_name:
frappe.throw( 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) 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].qty = 6 # Accepted Qty
scr.items[0].rejected_qty = 4 scr.items[0].rejected_qty = 4
scr.set_missing_values()
scr.save() scr.save()
# consumed_qty should be (accepted_qty * qty_consumed_per_unit) = (6 * 1) = 6 # 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 # ValidationError should not be raised as `Inspection Required before Purchase` is disabled
scr2.submit() scr2.submit()
def test_scrap_items_for_subcontracting_receipt(self): def test_secondary_items_for_subcontracting_receipt(self):
set_backflush_based_on("BOM") set_backflush_based_on("BOM")
fg_item = "Subcontracted Item SA1" fg_item = "Subcontracted Item SA1"
@@ -1166,9 +1167,9 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
] ]
# Create Scrap Items # Create Scrap Items
scrap_item_1 = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name secondary_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 secondary_item_2 = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name
scrap_items = [scrap_item_1, scrap_item_2] secondary_items = [secondary_item_1, secondary_item_2]
service_items = [ service_items = [
{ {
@@ -1187,13 +1188,14 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
) )
for idx, item in enumerate(bom.items): for idx, item in enumerate(bom.items):
item.qty = 1 * (idx + 1) item.qty = 1 * (idx + 1)
for idx, item in enumerate(scrap_items): for idx, item in enumerate(secondary_items):
bom.append( bom.append(
"scrap_items", "secondary_items",
{ {
"item_code": item, "item_code": item,
"stock_qty": 1 * (idx + 1), "stock_qty": 1 * (idx + 1),
"rate": 10 * (idx + 1), "rate": 10 * (idx + 1),
"is_legacy": 1,
}, },
) )
bom.save() bom.save()
@@ -1216,12 +1218,13 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
# Create Subcontracting Receipt # Create Subcontracting Receipt
scr = make_subcontracting_receipt(sco.name) scr = make_subcontracting_receipt(sco.name)
scr.save() 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_secondary_items = set(
scr_scrap_items = set([item.item_code for item in scr.items if item.is_scrap_item]) [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(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() scr.submit()

View File

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

View File

@@ -25,7 +25,7 @@ class SubcontractingReceiptItem(Document):
expense_account: DF.Link | None expense_account: DF.Link | None
image: DF.Attach | None image: DF.Attach | None
include_exploded_items: DF.Check include_exploded_items: DF.Check
is_scrap_item: DF.Check is_legacy_scrap_item: DF.Check
item_code: DF.Link item_code: DF.Link
item_name: DF.Data | None item_name: DF.Data | None
job_card: DF.Link | None job_card: DF.Link | None
@@ -36,6 +36,7 @@ class SubcontractingReceiptItem(Document):
parent: DF.Data parent: DF.Data
parentfield: DF.Data parentfield: DF.Data
parenttype: DF.Data parenttype: DF.Data
process_loss_qty: DF.Float
project: DF.Link | None project: DF.Link | None
purchase_order: DF.Link | None purchase_order: DF.Link | None
purchase_order_item: DF.Data | None purchase_order_item: DF.Data | None
@@ -52,7 +53,7 @@ class SubcontractingReceiptItem(Document):
rm_cost_per_qty: DF.Currency rm_cost_per_qty: DF.Currency
rm_supp_cost: DF.Currency rm_supp_cost: DF.Currency
schedule_date: DF.Date | None 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_and_batch_bundle: DF.Link | None
serial_no: DF.SmallText | None serial_no: DF.SmallText | None
service_cost_per_qty: DF.Currency service_cost_per_qty: DF.Currency
@@ -61,6 +62,7 @@ class SubcontractingReceiptItem(Document):
subcontracting_order: DF.Link | None subcontracting_order: DF.Link | None
subcontracting_order_item: DF.Data | None subcontracting_order_item: DF.Data | None
subcontracting_receipt_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 use_serial_batch_fields: DF.Check
warehouse: DF.Link | None warehouse: DF.Link | None
# end: auto-generated types # end: auto-generated types