diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 1fed02a23ed..05f2e18c878 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1435,7 +1435,7 @@ class StockController(AccountsController): elif self.doctype == "Stock Entry" and row.t_warehouse: qi_required = True # inward stock needs inspection - if row.get("is_scrap_item"): + if row.get("type") or row.get("is_legacy_scrap_item"): continue if qi_required: # validate row only if inspection is required on item level diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 6d886bd9ecf..e922a0ea9fc 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -160,7 +160,7 @@ class SubcontractingController(StockController): ).format(item.idx, get_link_to_form("Item", item.item_code)) ) - if not item.get("is_scrap_item"): + if not item.get("type") and not item.get("is_legacy_scrap_item"): if not is_sub_contracted_item: frappe.throw( _("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name) @@ -206,7 +206,7 @@ class SubcontractingController(StockController): ).format(item.idx, item.item_name) ) - if self.doctype != "Subcontracting Inward Order": + if self.doctype not in ["Subcontracting Inward Order", "Subcontracting Receipt"]: item.amount = item.qty * item.rate if item.bom: @@ -238,7 +238,7 @@ class SubcontractingController(StockController): and self._doc_before_save ): for row in self._doc_before_save.get("items"): - item_dict[row.name] = (row.item_code, row.qty + (row.get("rejected_qty") or 0)) + item_dict[row.name] = (row.item_code, row.received_qty) return item_dict @@ -264,7 +264,7 @@ class SubcontractingController(StockController): self.__reference_name.append(row.name) if (row.name not in item_dict) or ( row.item_code, - row.qty + (row.get("rejected_qty") or 0), + row.received_qty, ) != item_dict[row.name]: self.__changed_name.append(row.name) @@ -962,7 +962,7 @@ class SubcontractingController(StockController): ): qty = ( flt(bom_item.qty_consumed_per_unit) - * flt(row.qty + (row.get("rejected_qty") or 0)) + * flt(row.get("received_qty") or (row.qty + (row.get("rejected_qty") or 0))) * row.conversion_factor ) bom_item.main_item_code = row.item_code @@ -1285,22 +1285,28 @@ class SubcontractingController(StockController): if self.total_additional_costs: if self.distribute_additional_costs_based_on == "Amount": total_amt = sum( - flt(item.amount) for item in self.get("items") if not item.get("is_scrap_item") + flt(item.amount) + for item in self.get("items") + if not item.get("type") and not item.get("is_legacy_scrap_item") ) for item in self.items: - if not item.get("is_scrap_item"): + if not item.get("type") and not item.get("is_legacy_scrap_item"): item.additional_cost_per_qty = ( (item.amount * self.total_additional_costs) / total_amt ) / item.qty else: - total_qty = sum(flt(item.qty) for item in self.get("items") if not item.get("is_scrap_item")) + total_qty = sum( + flt(item.qty) + for item in self.get("items") + if not item.get("type") and not item.get("is_legacy_scrap_item") + ) additional_cost_per_qty = self.total_additional_costs / total_qty for item in self.items: - if not item.get("is_scrap_item"): + if not item.get("type") and not item.get("is_legacy_scrap_item"): item.additional_cost_per_qty = additional_cost_per_qty else: for item in self.items: - if not item.get("is_scrap_item"): + if not item.get("type") and not item.get("is_legacy_scrap_item"): item.additional_cost_per_qty = 0 @frappe.whitelist() diff --git a/erpnext/controllers/subcontracting_inward_controller.py b/erpnext/controllers/subcontracting_inward_controller.py index 1a3ff66b825..6428ca10822 100644 --- a/erpnext/controllers/subcontracting_inward_controller.py +++ b/erpnext/controllers/subcontracting_inward_controller.py @@ -1,3 +1,5 @@ +from collections import defaultdict + import frappe from frappe import _, bold from frappe.query_builder import Case @@ -18,7 +20,7 @@ class SubcontractingInwardController: def on_submit_subcontracting_inward(self): self.update_inward_order_item() self.update_inward_order_received_items() - self.update_inward_order_scrap_items() + self.update_inward_order_secondary_items() self.create_stock_reservation_entries_for_inward() self.update_inward_order_status() @@ -28,7 +30,7 @@ class SubcontractingInwardController: self.validate_delivery() self.validate_receive_from_customer_cancel() self.update_inward_order_received_items() - self.update_inward_order_scrap_items() + self.update_inward_order_secondary_items() self.remove_reference_for_additional_items() self.update_inward_order_status() @@ -239,7 +241,8 @@ class SubcontractingInwardController: item for item in self.get("items") if not item.is_finished_item - and not item.is_scrap_item + and not item.type + and not item.is_legacy_scrap_item and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item") ] @@ -368,7 +371,9 @@ class SubcontractingInwardController: if self.subcontracting_inward_order: if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]: for item in self.items: - if (item.is_finished_item or item.is_scrap_item) and item.valuation_rate == 0: + if ( + item.is_finished_item or item.type or item.is_legacy_scrap_item + ) and item.valuation_rate == 0: item.allow_zero_valuation_rate = 1 def validate_warehouse_(self): @@ -467,7 +472,7 @@ class SubcontractingInwardController: self.validate_delivery_on_save() else: for item in self.items: - if not item.is_scrap_item: + if not item.type and not item.is_legacy_scrap_item: delivered_qty, returned_qty = frappe.get_value( "Subcontracting Inward Order Item", item.scio_detail, @@ -519,7 +524,7 @@ class SubcontractingInwardController: if max_allowed_qty: max_allowed_qty = max_allowed_qty[0] else: - table = frappe.qb.DocType("Subcontracting Inward Order Scrap Item") + table = frappe.qb.DocType("Subcontracting Inward Order Secondary Item") query = ( frappe.qb.from_(table) .select((table.produced_qty - table.delivered_qty).as_("max_allowed_qty")) @@ -538,8 +543,8 @@ class SubcontractingInwardController: bold( frappe.get_cached_value( "Subcontracting Inward Order Item" - if not item.is_scrap_item - else "Subcontracting Inward Order Scrap Item", + if not item.type and not item.is_legacy_scrap_item + else "Subcontracting Inward Order Secondary Item", item.scio_detail, "stock_uom", ) @@ -590,9 +595,9 @@ class SubcontractingInwardController: ) for item in [item for item in self.items if not item.is_finished_item]: - if item.is_scrap_item: - scio_scrap_item = frappe.get_value( - "Subcontracting Inward Order Scrap Item", + if item.type or item.is_legacy_scrap_item: + scio_secondary_item = frappe.get_value( + "Subcontracting Inward Order Secondary Item", { "docstatus": 1, "item_code": item.item_code, @@ -603,12 +608,13 @@ class SubcontractingInwardController: as_dict=True, ) if ( - scio_scrap_item - and scio_scrap_item.delivered_qty > scio_scrap_item.produced_qty - item.transfer_qty + scio_secondary_item + and scio_secondary_item.delivered_qty + > scio_secondary_item.produced_qty - item.transfer_qty ): frappe.throw( _( - "Row #{0}: Cannot cancel this Manufacturing Stock Entry as quantity of Scrap Item {1} produced cannot be less than quantity delivered." + "Row #{0}: Cannot cancel this Manufacturing Stock Entry as quantity of Secondary Item {1} produced cannot be less than quantity delivered." ).format(item.idx, get_link_to_form("Item", item.item_code)) ) else: @@ -648,8 +654,8 @@ class SubcontractingInwardController: for item in self.items: doctype = ( "Subcontracting Inward Order Item" - if not item.is_scrap_item - else "Subcontracting Inward Order Scrap Item" + if not item.type and not item.is_legacy_scrap_item + else "Subcontracting Inward Order Secondary Item" ) frappe.db.set_value( doctype, @@ -763,7 +769,11 @@ class SubcontractingInwardController: customer_warehouse = frappe.get_cached_value( "Subcontracting Inward Order", self.subcontracting_inward_order, "customer_warehouse" ) - items = [item for item in self.items if not item.is_finished_item and not item.is_scrap_item] + items = [ + item + for item in self.items + if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item + ] item_code_wh = frappe._dict( { ( @@ -860,24 +870,24 @@ class SubcontractingInwardController: doc.insert() doc.submit() - def update_inward_order_scrap_items(self): + def update_inward_order_secondary_items(self): if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture": - scrap_items_list = [item for item in self.items if item.is_scrap_item] - scrap_items = frappe._dict( - { - (item.item_code, item.t_warehouse): item.transfer_qty - if self._action == "submit" - else -item.transfer_qty - for item in scrap_items_list - } - ) - if scrap_items: - item_codes, warehouses = zip(*list(scrap_items.keys()), strict=True) + secondary_items_list = [item for item in self.items if item.type or item.is_legacy_scrap_item] + + secondary_items = defaultdict(float) + for item in secondary_items_list: + secondary_items[(item.item_code, item.t_warehouse)] += ( + item.transfer_qty if self._action == "submit" else -item.transfer_qty + ) + secondary_items = frappe._dict(secondary_items) + + if secondary_items: + item_codes, warehouses = zip(*list(secondary_items.keys()), strict=True) item_codes = list(item_codes) warehouses = list(warehouses) result = frappe.get_all( - "Subcontracting Inward Order Scrap Item", + "Subcontracting Inward Order Secondary Item", filters={ "item_code": ["in", item_codes], "warehouse": ["in", warehouses], @@ -890,7 +900,7 @@ class SubcontractingInwardController: ) if result: - scrap_item_dict = frappe._dict( + secondary_items_dict = frappe._dict( { (d.item_code, d.warehouse): frappe._dict( {"name": d.name, "produced_qty": d.produced_qty} @@ -900,40 +910,45 @@ class SubcontractingInwardController: ) deleted_docs = [] case_expr = Case() - table = frappe.qb.DocType("Subcontracting Inward Order Scrap Item") - for key, value in scrap_item_dict.items(): - if self._action == "cancel" and value.produced_qty - abs(scrap_items.get(key)) == 0: + table = frappe.qb.DocType("Subcontracting Inward Order Secondary Item") + for key, value in secondary_items_dict.items(): + if ( + self._action == "cancel" + and value.produced_qty - abs(secondary_items.get(key)) == 0 + ): deleted_docs.append(value.name) - frappe.delete_doc("Subcontracting Inward Order Scrap Item", value.name) + frappe.delete_doc("Subcontracting Inward Order Secondary Item", value.name) else: case_expr = case_expr.when( - table.name == value.name, value.produced_qty + scrap_items.get(key) + table.name == value.name, value.produced_qty + secondary_items.get(key) ) if final_list := list( - set([v.name for v in scrap_item_dict.values()]) - set(deleted_docs) + set([v.name for v in secondary_items_dict.values()]) - set(deleted_docs) ): frappe.qb.update(table).set(table.produced_qty, case_expr).where( (table.name.isin(final_list)) & (table.docstatus == 1) ).run() fg_item_code = next(fg for fg in self.items if fg.is_finished_item).item_code - for scrap_item in [ + for secondary_item in [ item - for item in scrap_items_list + for item in secondary_items_list if (item.item_code, item.t_warehouse) not in [(d.item_code, d.warehouse) for d in result] ]: doc = frappe.new_doc( - "Subcontracting Inward Order Scrap Item", + "Subcontracting Inward Order Secondary Item", parent=scio, parenttype="Subcontracting Inward Order", - parentfield="scrap_items", - idx=frappe.db.count("Subcontracting Inward Order Scrap Item", {"parent": scio}) + 1, - item_code=scrap_item.item_code, + parentfield="secondary_items", + idx=frappe.db.count("Subcontracting Inward Order Secondary Item", {"parent": scio}) + + 1, + item_code=secondary_item.item_code, fg_item_code=fg_item_code, - stock_uom=scrap_item.stock_uom, - warehouse=scrap_item.t_warehouse, - produced_qty=scrap_item.transfer_qty, + stock_uom=secondary_item.stock_uom, + warehouse=secondary_item.t_warehouse, + produced_qty=secondary_item.transfer_qty, + type=secondary_item.type, delivered_qty=0, reference_name=frappe.get_value( "Work Order", self.work_order, "subcontracting_inward_order_item" @@ -965,7 +980,7 @@ class SubcontractingInwardController: and ( not frappe.db.exists("Subcontracting Inward Order Received Item", item.scio_detail) and not frappe.db.exists("Subcontracting Inward Order Item", item.scio_detail) - and not frappe.db.exists("Subcontracting Inward Order Scrap Item", item.scio_detail) + and not frappe.db.exists("Subcontracting Inward Order Secondary Item", item.scio_detail) ) ] for item in items: diff --git a/erpnext/controllers/tests/test_subcontracting_controller.py b/erpnext/controllers/tests/test_subcontracting_controller.py index 465b318d09b..0dbacb3c22d 100644 --- a/erpnext/controllers/tests/test_subcontracting_controller.py +++ b/erpnext/controllers/tests/test_subcontracting_controller.py @@ -501,8 +501,8 @@ class TestSubcontractingController(ERPNextTestSuite): scr1.items[0].qty = 2 add_second_row_in_scr(scr1) scr1.flags.ignore_mandatory = True - scr1.save() scr1.set_missing_values() + scr1.save() scr1.submit() for _key, value in get_supplied_items(scr1).items(): @@ -513,8 +513,8 @@ class TestSubcontractingController(ERPNextTestSuite): scr2.items[0].qty = 2 add_second_row_in_scr(scr2) scr2.flags.ignore_mandatory = True - scr2.save() scr2.set_missing_values() + scr2.save() scr2.submit() for _key, value in get_supplied_items(scr2).items(): @@ -523,8 +523,8 @@ class TestSubcontractingController(ERPNextTestSuite): scr3 = make_subcontracting_receipt(sco.name) scr3.items[0].qty = 2 scr3.flags.ignore_mandatory = True - scr3.save() scr3.set_missing_values() + scr3.save() scr3.submit() for _key, value in get_supplied_items(scr3).items(): @@ -1164,6 +1164,54 @@ class TestSubcontractingController(ERPNextTestSuite): self.assertEqual([item.rm_item_code for item in sco.supplied_items], expected) + def test_co_by_product(self): + frappe.set_value("UOM", "Nos", "must_be_whole_number", 0) + + fg_item = make_item("FG Item", properties={"is_stock_item": 1, "is_sub_contracted_item": 1}).name + rm_item = make_item("RM Item", properties={"is_stock_item": 1}).name + scrap_item = make_item("Scrap Item", properties={"is_stock_item": 1}).name + make_bom( + item=fg_item, raw_materials=[rm_item], scrap_items=[scrap_item], process_loss_percentage=10 + ).name + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 11", + "qty": 5, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 5, + }, + ] + sco = get_subcontracting_order(service_items=service_items) + rm_items = get_rm_items(sco.supplied_items) + itemwise_details = make_stock_in_entry(rm_items=rm_items) + make_stock_transfer_entry( + sco_no=sco.name, + rm_items=rm_items, + itemwise_details=copy.deepcopy(itemwise_details), + ) + + scr1 = make_subcontracting_receipt(sco.name) + scr1.get_secondary_items() + scr1.save() + + self.assertEqual(scr1.items[0].received_qty, 5) + self.assertEqual(scr1.items[0].process_loss_qty, 0.5) + self.assertEqual(scr1.items[0].qty, 4.5) + self.assertEqual(scr1.items[0].rate, 200) + self.assertEqual(scr1.items[0].amount, 900) + + self.assertEqual(scr1.items[1].item_code, scrap_item) + self.assertEqual(scr1.items[1].received_qty, 5) + self.assertEqual(scr1.items[1].process_loss_qty, 0.5) + self.assertEqual(scr1.items[1].qty, 4.5) + self.assertEqual(flt(scr1.items[1].rate, 3), 11.111) + self.assertEqual(scr1.items[1].amount, 50) + + frappe.set_value("UOM", "Nos", "must_be_whole_number", 1) + def add_second_row_in_scr(scr): item_dict = {} diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 454f1934e13..1dc64997198 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -620,10 +620,10 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr } item_code(doc, cdt, cdn) { - var scrap_items = false; + let secondary_items = false; var child = locals[cdt][cdn]; - if (child.doctype == "BOM Scrap Item") { - scrap_items = true; + if (child.doctype == "BOM Secondary Item") { + secondary_items = true; } if (child.bom_no) { @@ -634,7 +634,7 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr child.do_not_explode = 1; } - get_bom_material_detail(doc, cdt, cdn, scrap_items); + get_bom_material_detail(doc, cdt, cdn, secondary_items); } buying_price_list(doc) { @@ -683,7 +683,7 @@ cur_frm.cscript.is_default = function (doc) { if (doc.is_default) cur_frm.set_value("is_active", 1); }; -var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) { +var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) { if (!doc.company) { frappe.throw({ message: __("Please select a Company first."), title: __("Mandatory") }); } @@ -697,7 +697,6 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) { company: doc.company, item_code: d.item_code, bom_no: d.bom_no != null ? d.bom_no : "", - scrap_items: scrap_items, qty: d.qty, stock_qty: d.stock_qty, include_item_in_manufacturing: d.include_item_in_manufacturing, @@ -706,15 +705,15 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) { conversion_factor: d.conversion_factor, sourced_by_supplier: d.sourced_by_supplier, do_not_explode: d.do_not_explode, + fetch_rate: !secondary_items, }, callback: function (r) { $.extend(d, r.message); refresh_field("items"); - refresh_field("scrap_items"); + refresh_field("secondary_items"); doc = locals[doc.doctype][doc.name]; erpnext.bom.calculate_rm_cost(doc); - erpnext.bom.calculate_scrap_materials_cost(doc); erpnext.bom.calculate_total(doc); }, freeze: true, @@ -724,20 +723,18 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) { cur_frm.cscript.qty = function (doc) { erpnext.bom.calculate_rm_cost(doc); - erpnext.bom.calculate_scrap_materials_cost(doc); erpnext.bom.calculate_total(doc); }; cur_frm.cscript.rate = function (doc, cdt, cdn) { var d = locals[cdt][cdn]; - const is_scrap_item = cdt == "BOM Scrap Item"; + const is_secondary_item = cdt == "BOM Secondary Item"; if (d.bom_no) { frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item.")); - get_bom_material_detail(doc, cdt, cdn, is_scrap_item); + get_bom_material_detail(doc, cdt, cdn, is_secondary_item); } else { erpnext.bom.calculate_rm_cost(doc); - erpnext.bom.calculate_scrap_materials_cost(doc); erpnext.bom.calculate_total(doc); } }; @@ -745,7 +742,6 @@ cur_frm.cscript.rate = function (doc, cdt, cdn) { erpnext.bom.update_cost = function (doc) { erpnext.bom.calculate_op_cost(doc); erpnext.bom.calculate_rm_cost(doc); - erpnext.bom.calculate_scrap_materials_cost(doc); erpnext.bom.calculate_total(doc); }; @@ -804,34 +800,11 @@ erpnext.bom.calculate_rm_cost = function (doc) { cur_frm.set_value("base_raw_material_cost", base_total_rm_cost); }; -// sm : scrap material -erpnext.bom.calculate_scrap_materials_cost = function (doc) { - var sm = doc.scrap_items || []; - var total_sm_cost = 0; - var base_total_sm_cost = 0; - - for (var i = 0; i < sm.length; i++) { - var base_rate = flt(sm[i].rate) * flt(doc.conversion_rate); - var amount = flt(sm[i].rate) * flt(sm[i].stock_qty); - var base_amount = amount * flt(doc.conversion_rate); - - frappe.model.set_value("BOM Scrap Item", sm[i].name, "base_rate", base_rate); - frappe.model.set_value("BOM Scrap Item", sm[i].name, "amount", amount); - frappe.model.set_value("BOM Scrap Item", sm[i].name, "base_amount", base_amount); - - total_sm_cost += amount; - base_total_sm_cost += base_amount; - } - - cur_frm.set_value("scrap_material_cost", total_sm_cost); - cur_frm.set_value("base_scrap_material_cost", base_total_sm_cost); -}; - // Calculate Total Cost erpnext.bom.calculate_total = function (doc) { - var total_cost = flt(doc.operating_cost) + flt(doc.raw_material_cost) - flt(doc.scrap_material_cost); + var total_cost = flt(doc.operating_cost) + flt(doc.raw_material_cost) - flt(doc.secondary_items_cost); var base_total_cost = - flt(doc.base_operating_cost) + flt(doc.base_raw_material_cost) - flt(doc.base_scrap_material_cost); + flt(doc.base_operating_cost) + flt(doc.base_raw_material_cost) - flt(doc.base_secondary_items_cost); cur_frm.set_value("total_cost", total_cost); cur_frm.set_value("base_total_cost", base_total_cost); @@ -986,7 +959,7 @@ frappe.tour["BOM"] = [ }, ]; -frappe.ui.form.on("BOM Scrap Item", { +frappe.ui.form.on("BOM Secondary Item", { item_code(frm, cdt, cdn) { const { item_code } = locals[cdt][cdn]; }, @@ -1007,7 +980,7 @@ function trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code) { const row = locals[cdt][cdn]; row.stock_qty = (frm.doc.quantity * data.percent) / 100; row.qty = row.stock_qty / (row.conversion_factor || 1); - refresh_field("scrap_items"); + refresh_field("secondary_items"); }, __("Set Process Loss Item Quantity"), __("Set Quantity") diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index 491920a0f29..8574e58a498 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -16,6 +16,14 @@ "allow_alternative_item", "set_rate_of_sub_assembly_item_based_on_bom", "is_phantom_bom", + "cost_allocation_section", + "cost_allocation_per", + "column_break_srby", + "cost_allocation", + "process_loss_section", + "process_loss_percentage", + "column_break_ssj2", + "process_loss_qty", "currency_detail", "rm_cost_as_per", "buying_price_list", @@ -38,21 +46,16 @@ "operations", "materials_section", "items", - "scrap_section", - "scrap_items_section", - "scrap_items", - "process_loss_section", - "process_loss_percentage", - "column_break_ssj2", - "process_loss_qty", + "secondary_items_tab", + "secondary_items", "costing", "operating_cost", "raw_material_cost", - "scrap_material_cost", + "secondary_items_cost", "cb1", "base_operating_cost", "base_raw_material_cost", - "base_scrap_material_cost", + "base_secondary_items_cost", "column_break_26", "total_cost", "base_total_cost", @@ -298,19 +301,6 @@ "options": "BOM Item", "reqd": 1 }, - { - "collapsible": 1, - "depends_on": "eval:!doc.is_phantom_bom", - "fieldname": "scrap_section", - "fieldtype": "Tab Break", - "label": "Scrap & Process Loss" - }, - { - "fieldname": "scrap_items", - "fieldtype": "Table", - "label": "Scrap Items", - "options": "BOM Scrap Item" - }, { "fieldname": "costing", "fieldtype": "Tab Break", @@ -332,15 +322,6 @@ "options": "currency", "read_only": 1 }, - { - "depends_on": "eval:!doc.is_phantom_bom", - "fieldname": "scrap_material_cost", - "fieldtype": "Currency", - "label": "Scrap Material Cost", - "options": "currency", - "print_hide": 1, - "read_only": 1 - }, { "fieldname": "cb1", "fieldtype": "Column Break" @@ -362,15 +343,6 @@ "print_hide": 1, "read_only": 1 }, - { - "depends_on": "eval:!doc.is_phantom_bom", - "fieldname": "base_scrap_material_cost", - "fieldtype": "Currency", - "label": "Scrap Material Cost(Company Currency)", - "no_copy": 1, - "options": "Company:company:default_currency", - "read_only": 1 - }, { "fieldname": "total_cost", "fieldtype": "Currency", @@ -602,12 +574,6 @@ "fieldname": "column_break_ivyw", "fieldtype": "Column Break" }, - { - "fieldname": "scrap_items_section", - "fieldtype": "Section Break", - "hide_border": 1, - "label": "Scrap Items" - }, { "default": "0", "fieldname": "fg_based_operating_cost", @@ -706,6 +672,59 @@ "fieldname": "quality_inspection_tab", "fieldtype": "Tab Break", "label": "Quality Inspection" + }, + { + "fieldname": "secondary_items", + "fieldtype": "Table", + "label": "Secondary Items", + "options": "BOM Secondary Item" + }, + { + "depends_on": "eval:!doc.is_phantom_bom", + "fieldname": "secondary_items_cost", + "fieldtype": "Currency", + "label": "Secondary Items Cost", + "options": "currency", + "print_hide": 1, + "read_only": 1 + }, + { + "depends_on": "eval:!doc.is_phantom_bom", + "fieldname": "base_secondary_items_cost", + "fieldtype": "Currency", + "label": "Secondary Items Cost (Company Currency)", + "no_copy": 1, + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "secondary_items_tab", + "fieldtype": "Tab Break", + "label": "Secondary Items" + }, + { + "fieldname": "cost_allocation_section", + "fieldtype": "Section Break", + "label": "Cost Allocation" + }, + { + "fieldname": "column_break_srby", + "fieldtype": "Column Break" + }, + { + "fieldname": "cost_allocation", + "fieldtype": "Currency", + "label": "Cost Allocation", + "non_negative": 1, + "options": "currency", + "read_only": 1 + }, + { + "default": "100", + "fieldname": "cost_allocation_per", + "fieldtype": "Percent", + "label": "% Cost Allocation", + "non_negative": 1 } ], "icon": "fa fa-sitemap", @@ -713,7 +732,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2026-02-06 17:23:15.255301", + "modified": "2026-02-26 14:13:34.040181", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 2ee62b06ad5..a231eee9d84 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -113,19 +113,21 @@ class BOM(WebsiteGenerator): from erpnext.manufacturing.doctype.bom_explosion_item.bom_explosion_item import BOMExplosionItem from erpnext.manufacturing.doctype.bom_item.bom_item import BOMItem from erpnext.manufacturing.doctype.bom_operation.bom_operation import BOMOperation - from erpnext.manufacturing.doctype.bom_scrap_item.bom_scrap_item import BOMScrapItem + from erpnext.manufacturing.doctype.bom_secondary_item.bom_secondary_item import BOMSecondaryItem allow_alternative_item: DF.Check amended_from: DF.Link | None base_operating_cost: DF.Currency base_raw_material_cost: DF.Currency - base_scrap_material_cost: DF.Currency + base_secondary_items_cost: DF.Currency base_total_cost: DF.Currency bom_creator: DF.Link | None bom_creator_item: DF.Data | None buying_price_list: DF.Link | None company: DF.Link conversion_rate: DF.Float + cost_allocation: DF.Currency + cost_allocation_per: DF.Percent currency: DF.Link default_source_warehouse: DF.Link | None default_target_warehouse: DF.Link | None @@ -155,8 +157,8 @@ class BOM(WebsiteGenerator): rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List"] route: DF.SmallText | None routing: DF.Link | None - scrap_items: DF.Table[BOMScrapItem] - scrap_material_cost: DF.Currency + secondary_items: DF.Table[BOMSecondaryItem] + secondary_items_cost: DF.Currency set_rate_of_sub_assembly_item_based_on_bom: DF.Check show_in_website: DF.Check show_items: DF.Check @@ -284,7 +286,7 @@ class BOM(WebsiteGenerator): self.set_plc_conversion_rate() self.validate_uom_is_interger() self.set_bom_material_details() - self.set_bom_scrap_items_detail() + self.set_secondary_items_details() self.validate_materials() self.validate_transfer_against() self.set_routing_operations() @@ -294,9 +296,12 @@ class BOM(WebsiteGenerator): self.update_stock_qty() self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False) self.set_process_loss_qty() - self.validate_scrap_items() + self.validate_uoms() self.set_default_uom() self.validate_semi_finished_goods() + self.validate_secondary_items() + self.set_fg_cost_allocation() + self.validate_total_cost_allocation() if self.docstatus == 1: self.validate_raw_materials_of_operation() @@ -326,6 +331,22 @@ class BOM(WebsiteGenerator): ), ) + def validate_secondary_items(self): + for item in self.secondary_items: + if not item.qty: + frappe.throw( + _("Row #{0}: Quantity should be greater than 0 for {1} Item {2}").format( + item.idx, item.type, get_link_to_form("Item", item.item_code) + ) + ) + + if item.process_loss_per >= 100: + frappe.throw( + _("Row #{0}: Process Loss Percentage should be less than 100% for {1} Item {2}").format( + item.idx, item.type, get_link_to_form("Item", item.item_code) + ) + ) + def validate_raw_materials_of_operation(self): if not self.track_semi_finished_goods or not self.operations: return @@ -401,6 +422,24 @@ class BOM(WebsiteGenerator): doc = frappe.get_doc("BOM Creator", self.bom_creator) doc.set_status(save=True) + def set_fg_cost_allocation(self): + total_secondary_items_per = 0 + for item in self.secondary_items: + total_secondary_items_per += item.cost_allocation_per + + if self.cost_allocation_per == 100 and total_secondary_items_per: + self.cost_allocation_per -= total_secondary_items_per + + self.cost_allocation = self.raw_material_cost * (self.cost_allocation_per / 100) + + def validate_total_cost_allocation(self): + total_cost_allocation_per = self.cost_allocation_per + for item in self.secondary_items: + total_cost_allocation_per += item.cost_allocation_per + + if total_cost_allocation_per != 100: + frappe.throw(_("Cost allocation between finished goods and secondary items should equal 100%")) + def on_update_after_submit(self): self.validate_bom_links() self.manage_default_bom() @@ -462,6 +501,7 @@ class BOM(WebsiteGenerator): "conversion_factor": item.conversion_factor, "sourced_by_supplier": item.sourced_by_supplier, "do_not_explode": item.do_not_explode, + "fetch_rate": True, } ) @@ -469,13 +509,13 @@ class BOM(WebsiteGenerator): if not item.get(r): item.set(r, ret[r]) - def set_bom_scrap_items_detail(self): - for item in self.get("scrap_items"): + def set_secondary_items_details(self): + for item in self.get("secondary_items"): args = { "item_code": item.item_code, "company": self.company, - "scrap_items": True, - "bom_no": "", + "uom": item.uom, + "fetch_rate": False, } ret = self.get_bom_material_detail(args) for key, value in ret.items(): @@ -495,7 +535,7 @@ class BOM(WebsiteGenerator): item = self.get_item_det(args["item_code"]) - args["bom_no"] = args["bom_no"] or item and cstr(item["default_bom"]) or "" + args["bom_no"] = args.get("bom_no") or item and cstr(item["default_bom"]) or "" args["transfer_for_manufacture"] = ( cstr(args.get("include_item_in_manufacturing", "")) or item @@ -504,7 +544,7 @@ class BOM(WebsiteGenerator): ) args.update(item) - rate = self.get_rm_rate(args) + rate = self.get_rm_rate(args) if args.get("fetch_rate") else 0 ret_item = { "item_name": item and args["item_name"] or "", "description": item and args["description"] or "", @@ -546,9 +586,7 @@ class BOM(WebsiteGenerator): if not self.rm_cost_as_per: self.rm_cost_as_per = "Valuation Rate" - if arg.get("scrap_items"): - rate = get_valuation_rate(arg) - elif arg: + if arg: # Customer Provided parts and Supplier sourced parts will have zero rate if not frappe.db.get_value("Item", arg["item_code"], "is_customer_provided_item") and not arg.get( "sourced_by_supplier" @@ -688,7 +726,7 @@ class BOM(WebsiteGenerator): ) def update_stock_qty(self): - for m in self.get("items"): + for m in self.get("items") + self.get("secondary_items"): if not m.conversion_factor: m.conversion_factor = flt(get_conversion_factor(m.item_code, m.uom)["conversion_factor"]) if m.uom and m.qty: @@ -889,16 +927,16 @@ class BOM(WebsiteGenerator): """Calculate bom totals""" self.calculate_op_cost(update_hour_rate) self.calculate_rm_cost(save=save_updates) - self.calculate_sm_cost(save=save_updates) + self.calculate_secondary_items_costs(save=save_updates) if save_updates: # not via doc event, table is not regenerated and needs updation self.calculate_exploded_cost() old_cost = self.total_cost - self.total_cost = self.operating_cost + self.raw_material_cost - self.scrap_material_cost + self.total_cost = self.operating_cost + self.raw_material_cost - self.secondary_items_cost self.base_total_cost = ( - self.base_operating_cost + self.base_raw_material_cost - self.base_scrap_material_cost + self.base_operating_cost + self.base_raw_material_cost - self.base_secondary_items_cost ) if self.total_cost != old_cost: @@ -997,29 +1035,24 @@ class BOM(WebsiteGenerator): self.raw_material_cost = total_rm_cost self.base_raw_material_cost = base_total_rm_cost - def calculate_sm_cost(self, save=False): + def calculate_secondary_items_costs(self, save=False): """Fetch RM rate as per today's valuation rate and calculate totals""" total_sm_cost = 0 base_total_sm_cost = 0 + precision = self.precision("raw_material_cost") - for d in self.get("scrap_items"): - d.base_rate = flt(d.rate, d.precision("rate")) * flt( - self.conversion_rate, self.precision("conversion_rate") - ) - d.amount = flt( - flt(d.rate, d.precision("rate")) * flt(d.stock_qty, d.precision("stock_qty")), - d.precision("amount"), - ) - d.base_amount = flt(d.amount, d.precision("amount")) * flt( - self.conversion_rate, self.precision("conversion_rate") - ) - total_sm_cost += d.amount - base_total_sm_cost += d.base_amount - if save: - d.db_update() + for d in self.get("secondary_items"): + if not d.is_legacy: + d.cost = flt(self.raw_material_cost * (d.cost_allocation_per / 100), precision) + d.base_cost = flt(d.cost * self.conversion_rate, precision) - self.scrap_material_cost = total_sm_cost - self.base_scrap_material_cost = base_total_sm_cost + total_sm_cost += d.cost + base_total_sm_cost += d.base_cost + if save: + d.db_update() + + self.secondary_items_cost = total_sm_cost + self.base_secondary_items_cost = base_total_sm_cost def calculate_exploded_cost(self): "Set exploded row cost from it's parent BOM." @@ -1221,16 +1254,29 @@ class BOM(WebsiteGenerator): if self.process_loss_percentage: self.process_loss_qty = flt(self.quantity) * flt(self.process_loss_percentage) / 100 - def validate_scrap_items(self): - must_be_whole_number = frappe.get_value("UOM", self.uom, "must_be_whole_number") + for item in self.secondary_items: + item.process_loss_qty = flt( + item.stock_qty * (item.process_loss_per / 100), self.precision("quantity") + ) - if self.process_loss_percentage and self.process_loss_percentage > 100: + def validate_uoms(self): + self.validate_uom(self.item, self.uom, self.process_loss_percentage, self.process_loss_qty) + for item in self.secondary_items: + self.validate_uom(item.item_code, item.stock_uom, item.process_loss_per, item.process_loss_qty) + + def validate_uom(self, item_code, uom, process_loss_per, process_loss_qty): + must_be_whole_number = frappe.get_value("UOM", uom, "must_be_whole_number") + + if process_loss_per and process_loss_per > 100: frappe.throw(_("Process Loss Percentage cannot be greater than 100")) - if self.process_loss_qty and must_be_whole_number and self.process_loss_qty % 1 != 0: - msg = f"Item: {frappe.bold(self.item)} with Stock UOM: {frappe.bold(self.uom)} can't have fractional process loss qty as UOM {frappe.bold(self.uom)} is a whole Number." + if process_loss_qty and must_be_whole_number and process_loss_qty % 1 != 0: + msg = f"Item: {frappe.bold(item_code)} with Stock UOM: {frappe.bold(uom)} can't have fractional process loss qty as UOM {frappe.bold(uom)} is a whole Number." frappe.throw(msg, title=_("Invalid Process Loss Configuration")) + def has_scrap_items(self): + return any(d.get("type") == "Scrap" or d.get("is_legacy") for d in self.get("secondary_items")) + def get_bom_item_rate(args, bom_doc): if bom_doc.rm_cost_as_per == "Valuation Rate": @@ -1332,7 +1378,7 @@ def get_bom_items_as_dict( company, qty=1, fetch_exploded=1, - fetch_scrap_items=0, + fetch_secondary_items=0, include_non_stock_items=False, fetch_qty_in_stock_uom=True, ): @@ -1343,7 +1389,7 @@ def get_bom_items_as_dict( fetch_exploded = 0 group_by_cond = "group by item_code, operation_row_id, stock_uom" - if fetch_scrap_items: + if fetch_secondary_items: fetch_exploded = 0 group_by_cond = "group by item_code" @@ -1355,8 +1401,6 @@ def get_bom_items_as_dict( sum(bom_item.{qty_field}/ifnull(bom.quantity, 1)) * %(qty)s as qty, item.image, bom.project, - bom_item.rate, - sum(bom_item.{qty_field}/ifnull(bom.quantity, 1)) * bom_item.rate * %(qty)s as amount, item.stock_uom, item.item_group, item.allow_alternative_item, @@ -1388,17 +1432,18 @@ def get_bom_items_as_dict( group_by_cond=group_by_cond, select_columns=""", bom_item.source_warehouse, bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.description, bom_item.rate, bom_item.sourced_by_supplier, + sum(bom_item.stock_qty/ifnull(bom.quantity, 1)) * bom_item.rate * %(qty)s as amount, (Select idx from `tabBOM Item` where item_code = bom_item.item_code and parent = %(parent)s limit 1) as idx""", ) items = frappe.db.sql( query, {"parent": bom, "qty": qty, "bom": bom, "company": company}, as_dict=True ) - elif fetch_scrap_items: + elif fetch_secondary_items: query = query.format( - table="BOM Scrap Item", + table="BOM Secondary Item", where_conditions=")", - select_columns=", item.description", + select_columns=", item.description, bom_item.cost_allocation_per, bom_item.process_loss_per, bom_item.type, bom_item.name, bom_item.is_legacy", is_stock_item=is_stock_item, qty_field="stock_qty", group_by_cond=group_by_cond, @@ -1411,8 +1456,9 @@ def get_bom_items_as_dict( where_conditions="or bom_item.is_phantom_item)", is_stock_item=is_stock_item, qty_field="stock_qty" if fetch_qty_in_stock_uom else "qty", - select_columns=""", bom_item.uom, bom_item.conversion_factor, bom_item.source_warehouse, + select_columns=""", bom_item.rate, bom_item.uom, bom_item.conversion_factor, bom_item.source_warehouse, bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.sourced_by_supplier, + sum(bom_item.stock_qty/ifnull(bom.quantity, 1)) * bom_item.rate * %(qty)s as amount, bom_item.description, bom_item.base_rate as rate, bom_item.operation_row_id, bom_item.is_phantom_item , bom_item.bom_no """, group_by_cond=group_by_cond, ) @@ -1432,7 +1478,7 @@ def get_bom_items_as_dict( company, qty=item.get("qty"), fetch_exploded=fetch_exploded, - fetch_scrap_items=fetch_scrap_items, + fetch_secondary_items=fetch_secondary_items, include_non_stock_items=include_non_stock_items, fetch_qty_in_stock_uom=fetch_qty_in_stock_uom, ) @@ -1482,7 +1528,7 @@ def validate_bom_no(item, bom_no): for d in bom.items: if d.item_code.lower() == item.lower(): rm_item_exists = True - for d in bom.scrap_items: + for d in bom.secondary_items: if d.item_code.lower() == item.lower(): rm_item_exists = True if ( @@ -1773,7 +1819,7 @@ def get_bom_diff(bom1, bom2): identifiers = { "operations": "operation", "items": "item_code", - "scrap_items": "item_code", + "secondary_items": "item_code", "exploded_items": "item_code", } @@ -1919,9 +1965,9 @@ def get_op_cost_from_sub_assemblies(bom_no, op_cost=0): return op_cost -def get_scrap_items_from_sub_assemblies(bom_no, company, qty, scrap_items=None): - if not scrap_items: - scrap_items = {} +def get_secondary_items_from_sub_assemblies(bom_no, company, qty, secondary_items=None): + if not secondary_items: + secondary_items = {} bom_items = frappe.get_all( "BOM Item", @@ -1935,9 +1981,9 @@ def get_scrap_items_from_sub_assemblies(bom_no, company, qty, scrap_items=None): continue qty = flt(row.qty) * flt(qty) - items = get_bom_items_as_dict(row.bom_no, company, qty=qty, fetch_exploded=0, fetch_scrap_items=1) - scrap_items.update(items) + items = get_bom_items_as_dict(row.bom_no, company, qty=qty, fetch_exploded=0, fetch_secondary_items=1) + secondary_items.update(items) - get_scrap_items_from_sub_assemblies(row.bom_no, company, qty, scrap_items) + get_secondary_items_from_sub_assemblies(row.bom_no, company, qty, secondary_items) - return scrap_items + return secondary_items diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 68a29d7da4e..3296559afc5 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -895,7 +895,7 @@ def create_bom_with_process_loss_item( if scrap_qty: bom_doc.append( - "scrap_items", + "secondary_items", { "item_code": fg_item.item_code, "qty": scrap_qty, diff --git a/erpnext/manufacturing/doctype/bom/test_records.json b/erpnext/manufacturing/doctype/bom/test_records.json index 27752d85119..7c5c41fec19 100644 --- a/erpnext/manufacturing/doctype/bom/test_records.json +++ b/erpnext/manufacturing/doctype/bom/test_records.json @@ -36,15 +36,17 @@ "quantity": 1.0 }, { - "scrap_items":[ + "secondary_items":[ { "amount": 2000.0, - "doctype": "BOM Scrap Item", + "doctype": "BOM Secondary Item", "item_code": "_Test Item Home Desktop 100", - "parentfield": "scrap_items", + "parentfield": "secondary_items", "stock_qty": 1.0, "rate": 2000.0, - "stock_uom": "_Test UOM" + "stock_uom": "_Test UOM", + "type": "Scrap", + "is_legacy": 1 } ], "items": [ diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py index e071dadb998..e3feac1061a 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py @@ -356,7 +356,6 @@ class BOMCreator(Document): { "bom_no": bom_no, "allow_alternative_item": 1, - "allow_scrap_items": not item.get("is_phantom_item"), "include_item_in_manufacturing": 1, } ) diff --git a/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json b/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json deleted file mode 100644 index e782a882e8b..00000000000 --- a/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json +++ /dev/null @@ -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 -} diff --git a/erpnext/manufacturing/doctype/bom_scrap_item/__init__.py b/erpnext/manufacturing/doctype/bom_secondary_item/__init__.py similarity index 100% rename from erpnext/manufacturing/doctype/bom_scrap_item/__init__.py rename to erpnext/manufacturing/doctype/bom_secondary_item/__init__.py diff --git a/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.json b/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.json new file mode 100644 index 00000000000..39fa55123f4 --- /dev/null +++ b/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.json @@ -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": [] +} diff --git a/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.py b/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.py similarity index 50% rename from erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.py rename to erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.py index 043bbc63b50..87748fe2269 100644 --- a/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.py +++ b/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.py @@ -1,11 +1,11 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - +# import frappe from frappe.model.document import Document -class BOMScrapItem(Document): +class BOMSecondaryItem(Document): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -14,17 +14,26 @@ class BOMScrapItem(Document): if TYPE_CHECKING: from frappe.types import DF - amount: DF.Currency - base_amount: DF.Currency - base_rate: DF.Currency + base_cost: DF.Currency + conversion_factor: DF.Float + cost: DF.Currency + cost_allocation_per: DF.Percent + description: DF.TextEditor | None + image: DF.AttachImage | None + is_legacy: DF.Check item_code: DF.Link item_name: DF.Data | None parent: DF.Data parentfield: DF.Data parenttype: DF.Data + process_loss_per: DF.Percent + process_loss_qty: DF.Float + qty: DF.Float rate: DF.Currency stock_qty: DF.Float stock_uom: DF.Link | None + type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"] + uom: DF.Link # end: auto-generated types pass diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 9fb7dcb51b2..68d1e3e6214 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -23,7 +23,7 @@ frappe.ui.form.on("Job Card", { }; }); - frm.set_query("item_code", "scrap_items", () => { + frm.set_query("item_code", "secondary_items", () => { return { filters: { disabled: 0, @@ -104,7 +104,7 @@ frappe.ui.form.on("Job Card", { frm.doc.docstatus === 1 && !frm.doc.is_subcontracted && (frm.doc.skip_material_transfer || frm.doc.transferred_qty > 0) && - flt(frm.doc.for_quantity) + flt(frm.doc.process_loss_qty) > flt(frm.doc.manufactured_qty) + flt(frm.doc.manufactured_qty) + flt(frm.doc.process_loss_qty) < flt(frm.doc.for_quantity) ) { frm.add_custom_button(__("Make Stock Entry"), () => { frappe.confirm( @@ -278,8 +278,6 @@ frappe.ui.form.on("Job Card", { frm.trigger("complete_job_card"); }); } - - frm.trigger("make_dashboard"); } } diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 6b34eb7711a..728e8fc27ec 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -59,8 +59,8 @@ "time_logs", "section_break_21", "sub_operations", - "scrap_items_section", - "scrap_items", + "secondary_items_section", + "secondary_items", "corrective_operation_section", "for_job_card", "is_corrective_job_card", @@ -406,20 +406,6 @@ "options": "Batch", "read_only": 1 }, - { - "collapsible": 1, - "fieldname": "scrap_items_section", - "fieldtype": "Tab Break", - "label": "Scrap Items" - }, - { - "fieldname": "scrap_items", - "fieldtype": "Table", - "label": "Scrap Items", - "no_copy": 1, - "options": "Job Card Scrap Item", - "print_hide": 1 - }, { "fetch_from": "operation.quality_inspection_template", "fieldname": "quality_inspection_template", @@ -623,12 +609,26 @@ { "fieldname": "column_break_xhzg", "fieldtype": "Column Break" + }, + { + "fieldname": "secondary_items", + "fieldtype": "Table", + "label": "Secondary Items", + "no_copy": 1, + "options": "Job Card Secondary Item", + "print_hide": 1 + }, + { + "collapsible": 1, + "fieldname": "secondary_items_section", + "fieldtype": "Tab Break", + "label": "Secondary Items" } ], "grid_page_length": 50, "is_submittable": 1, "links": [], - "modified": "2026-02-06 18:27:03.178783", + "modified": "2026-02-26 15:13:56.767070", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 0f4c9d569fa..a4eaec8e73f 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -71,7 +71,9 @@ class JobCard(Document): from erpnext.manufacturing.doctype.job_card_scheduled_time.job_card_scheduled_time import ( JobCardScheduledTime, ) - from erpnext.manufacturing.doctype.job_card_scrap_item.job_card_scrap_item import JobCardScrapItem + from erpnext.manufacturing.doctype.job_card_secondary_item.job_card_secondary_item import ( + JobCardSecondaryItem, + ) from erpnext.manufacturing.doctype.job_card_time_log.job_card_time_log import JobCardTimeLog actual_end_date: DF.Datetime | None @@ -110,7 +112,7 @@ class JobCard(Document): remarks: DF.SmallText | None requested_qty: DF.Float scheduled_time_logs: DF.Table[JobCardScheduledTime] - scrap_items: DF.Table[JobCardScrapItem] + secondary_items: DF.Table[JobCardSecondaryItem] semi_fg_bom: DF.Link | None sequence_id: DF.Int serial_and_batch_bundle: DF.Link | None @@ -199,6 +201,7 @@ class JobCard(Document): def set_manufactured_qty(self): table_name = "Stock Entry" + child_name = "Stock Entry Detail" if self.is_subcontracted: table_name = "Subcontracting Receipt Item" @@ -208,8 +211,13 @@ class JobCard(Document): if self.is_subcontracted: query = query.select(Sum(table.qty)) else: - query = query.select(Sum(table.fg_completed_qty)) - query = query.where(table.purpose == "Manufacture") + child = frappe.qb.DocType(child_name) + query = ( + query.join(child) + .on(table.name == child.parent) + .select(Sum(child.transfer_qty)) + .where((table.purpose == "Manufacture") & (child.is_finished_item == 1)) + ) qty = query.run()[0][0] or 0.0 self.manufactured_qty = flt(qty) @@ -267,25 +275,35 @@ class JobCard(Document): row.sub_operation = row.operation self.append("sub_operations", row) - def set_scrap_items(self): - if not self.semi_fg_bom: + def set_secondary_items(self): + if not self.semi_fg_bom and not self.bom_no: return items_dict = get_bom_items_as_dict( - self.semi_fg_bom, self.company, qty=self.for_quantity, fetch_exploded=0, fetch_scrap_items=1 + self.semi_fg_bom or self.bom_no, + self.company, + qty=self.for_quantity, + fetch_exploded=0, + fetch_secondary_items=1, ) for item_code, values in items_dict.items(): values = frappe._dict(values) + secondary_item = { + "item_code": item_code, + "stock_qty": values.qty, + "item_name": values.item_name, + "stock_uom": values.stock_uom, + "type": values.type, + "bom_secondary_item": values.name, + } - self.append( - "scrap_items", - { - "item_code": item_code, - "stock_qty": values.qty, - "item_name": values.item_name, - "stock_uom": values.stock_uom, - }, - ) + if not values.is_legacy: + secondary_item["stock_qty"] -= flt( + secondary_item["stock_qty"] * (values.process_loss_per / 100), + self.precision("for_quantity"), + ) + + self.append("secondary_items", secondary_item) def validate_time_logs(self, save=False): self.total_time_in_mins = 0.0 @@ -1181,7 +1199,7 @@ class JobCard(Document): def set_status(self, update_status=False): self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0] if self.finished_good and self.docstatus == 1: - if self.manufactured_qty >= self.for_quantity: + if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity: self.status = "Completed" elif self.transferred_qty > 0 or self.skip_material_transfer: self.status = "Work In Progress" @@ -1456,12 +1474,24 @@ class JobCard(Document): ) @frappe.whitelist() - def make_stock_entry_for_semi_fg_item(self, auto_submit=False): + def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False): + def get_consumed_process_loss(): + table = frappe.qb.DocType("Stock Entry") + query = ( + frappe.qb.from_(table) + .select(Sum(table.process_loss_qty)) + .where( + (table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1) + ) + ) + return query.run()[0][0] or 0 + from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry ste = ManufactureEntry( { "for_quantity": self.for_quantity - self.manufactured_qty, + "process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0), "job_card": self.name, "skip_material_transfer": self.skip_material_transfer, "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, @@ -1481,9 +1511,10 @@ class JobCard(Document): wo_doc = frappe.get_doc("Work Order", self.work_order) add_additional_cost(ste.stock_entry, wo_doc, self) - ste.stock_entry.set_scrap_items() + ste.stock_entry.pro_doc = frappe.get_doc("Work Order", self.work_order) + ste.stock_entry.set_secondary_items_from_job_card() for row in ste.stock_entry.items: - if row.is_scrap_item and not row.t_warehouse: + if (row.type or row.is_legacy_scrap_item) and not row.t_warehouse: row.t_warehouse = self.target_warehouse if auto_submit: diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 556d3911eb3..a25b6e1af3d 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -882,6 +882,193 @@ class TestJobCard(ERPNextTestSuite): s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6)) self.assertEqual(s.additional_costs[0].amount, 8) + def test_co_by_product_for_sfg_flow(self): + from erpnext.manufacturing.doctype.operation.test_operation import make_operation + + frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0) + + def create_bom(raw_material, finished_good, scrap_item, submit=True): + bom = frappe.new_doc("BOM") + bom.company = "_Test Company" + bom.item = finished_good + bom.quantity = 1 + bom.append("items", {"item_code": raw_material, "qty": 1}) + bom.append( + "secondary_items", + { + "item_code": scrap_item, + "qty": 1, + "process_loss_per": 10, + "cost_allocation_per": 5, + "type": "Scrap", + }, + ) + if submit: + bom.insert() + bom.submit() + + return bom + + rm1 = create_item("RM 1") + scrap1 = create_item("Scrap 1") + sfg = create_item("SFG 1") + sfg_bom = create_bom(rm1.name, sfg.name, scrap1.name) + + rm2 = create_item("RM 2") + fg1 = create_item("FG 1") + scrap2 = create_item("Scrap 2") + scrap_extra = create_item("Scrap Extra") + fg_bom = create_bom(rm2.name, fg1.name, scrap2.name, submit=False) + fg_bom.with_operations = 1 + fg_bom.track_semi_finished_goods = 1 + + operation1 = { + "operation": "Test Operation A", + "workstation": "_Test Workstation A", + "finished_good": sfg.name, + "bom_no": sfg_bom.name, + "finished_good_qty": 1, + "sequence_id": 1, + "time_in_mins": 30, + } + operation2 = { + "operation": "Test Operation B", + "workstation": "_Test Workstation A", + "finished_good": fg1.name, + "bom_no": fg_bom.name, + "finished_good_qty": 1, + "is_final_finished_good": 1, + "sequence_id": 2, + "time_in_mins": 30, + } + + make_workstation(operation1) + make_operation(operation1) + make_operation(operation2) + + fg_bom.append("operations", operation1) + fg_bom.append("operations", operation2) + fg_bom.append("items", {"item_code": sfg.name, "qty": 1, "uom": "Nos", "operation_row_id": 2}) + fg_bom.insert() + fg_bom.save() + fg_bom.submit() + + work_order = make_wo_order_test_record( + item=fg1.name, + qty=10, + source_warehouse="Stores - _TC", + fg_warehouse="Finished Goods - _TC", + bom_no=fg_bom.name, + skip_transfer=1, + do_not_save=True, + ) + + work_order.operations[0].time_in_mins = 60 + work_order.operations[1].time_in_mins = 60 + work_order.save() + work_order.submit() + + job_card = frappe.get_doc( + "Job Card", + frappe.db.get_value( + "Job Card", {"work_order": work_order.name, "operation": "Test Operation A"}, "name" + ), + ) + job_card.append( + "time_logs", + { + "from_time": "2009-01-01 12:06:25", + "to_time": "2009-01-01 12:37:25", + "completed_qty": job_card.for_quantity, + }, + ) + job_card.append( + "secondary_items", {"item_code": scrap_extra.name, "stock_qty": 5, "type": "Co-Product"} + ) + job_card.submit() + + for row in sfg_bom.items: + make_stock_entry( + item_code=row.item_code, + target="Stores - _TC", + qty=10, + basic_rate=100, + ) + + manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()) + manufacturing_entry.submit() + + self.assertEqual(manufacturing_entry.items[2].item_code, scrap1.name) + self.assertEqual(manufacturing_entry.items[2].qty, 9) + self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556) + self.assertEqual(manufacturing_entry.items[3].item_code, scrap_extra.name) + self.assertEqual(manufacturing_entry.items[3].type, "Co-Product") + self.assertEqual(manufacturing_entry.items[3].qty, 5) + self.assertEqual(manufacturing_entry.items[3].basic_rate, 0) + + job_card = frappe.get_doc( + "Job Card", + frappe.db.get_value( + "Job Card", {"work_order": work_order.name, "operation": "Test Operation B"}, "name" + ), + ) + job_card.append( + "time_logs", + { + "from_time": "2009-02-01 12:06:25", + "to_time": "2009-02-01 12:37:25", + "completed_qty": job_card.for_quantity, + }, + ) + job_card.submit() + + for row in fg_bom.items: + make_stock_entry( + item_code=row.item_code, + target="Stores - _TC", + qty=10, + basic_rate=100, + ) + + manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()) + manufacturing_entry.submit() + + self.assertEqual(manufacturing_entry.items[2].item_code, scrap2.name) + self.assertEqual(manufacturing_entry.items[2].qty, 9) + self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556) + + def test_secondary_items_without_sfg(self): + for row in frappe.get_doc("BOM", self.work_order.bom_no).items: + make_stock_entry( + item_code=row.item_code, + target="_Test Warehouse - _TC", + qty=10, + basic_rate=100, + ) + + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + job_card.append("secondary_items", {"item_code": "_Test Item", "stock_qty": 2, "type": "Scrap"}) + job_card.append( + "time_logs", + { + "from_time": "2009-01-01 12:06:25", + "to_time": "2009-01-01 12:37:25", + "completed_qty": job_card.for_quantity, + }, + ) + job_card.save() + job_card.submit() + + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_for_wo, + ) + + s = frappe.get_doc(make_stock_entry_for_wo(self.work_order.name, "Manufacture")) + s.submit() + + self.assertEqual(s.items[3].item_code, "_Test Item") + self.assertEqual(s.items[3].transfer_qty, 2) + def create_bom_with_multiple_operations(): "Create a BOM with multiple operations and Material Transfer against Job Card" diff --git a/erpnext/manufacturing/doctype/job_card_scrap_item/__init__.py b/erpnext/manufacturing/doctype/job_card_secondary_item/__init__.py similarity index 100% rename from erpnext/manufacturing/doctype/job_card_scrap_item/__init__.py rename to erpnext/manufacturing/doctype/job_card_secondary_item/__init__.py diff --git a/erpnext/manufacturing/doctype/job_card_scrap_item/job_card_scrap_item.json b/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.json similarity index 73% rename from erpnext/manufacturing/doctype/job_card_scrap_item/job_card_scrap_item.json rename to erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.json index fdb8ec44bdc..d9ac0e08ced 100644 --- a/erpnext/manufacturing/doctype/job_card_scrap_item/job_card_scrap_item.json +++ b/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.json @@ -5,10 +5,12 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "type", + "description", + "column_break_3", "item_code", "item_name", - "column_break_3", - "description", + "bom_secondary_item", "quantity_and_rate", "stock_qty", "column_break_6", @@ -19,7 +21,7 @@ "fieldname": "item_code", "fieldtype": "Link", "in_list_view": 1, - "label": "Scrap Item Code", + "label": "Secondary Item Code", "options": "Item", "reqd": 1 }, @@ -28,7 +30,7 @@ "fieldname": "item_name", "fieldtype": "Data", "in_list_view": 1, - "label": "Scrap Item Name" + "label": "Secondary Item Name" }, { "fieldname": "column_break_3", @@ -65,20 +67,36 @@ "label": "Stock UOM", "options": "UOM", "read_only": 1 + }, + { + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "options": "Co-Product\nBy-Product\nScrap\nAdditional Finished Good", + "reqd": 1 + }, + { + "fieldname": "bom_secondary_item", + "fieldtype": "Data", + "hidden": 1, + "label": "BOM Secondary Item Reference", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-07-29 13:09:57.323835", + "modified": "2026-03-06 13:51:00.492621", "modified_by": "Administrator", "module": "Manufacturing", - "name": "Job Card Scrap Item", + "name": "Job Card Secondary Item", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/job_card_scrap_item/job_card_scrap_item.py b/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.py similarity index 78% rename from erpnext/manufacturing/doctype/job_card_scrap_item/job_card_scrap_item.py rename to erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.py index e4b926efc07..3a71ab9d755 100644 --- a/erpnext/manufacturing/doctype/job_card_scrap_item/job_card_scrap_item.py +++ b/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.py @@ -4,7 +4,7 @@ from frappe.model.document import Document -class JobCardScrapItem(Document): +class JobCardSecondaryItem(Document): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -13,6 +13,7 @@ class JobCardScrapItem(Document): if TYPE_CHECKING: from frappe.types import DF + bom_secondary_item: DF.Data | None description: DF.SmallText | None item_code: DF.Link item_name: DF.Data | None @@ -21,6 +22,7 @@ class JobCardScrapItem(Document): parenttype: DF.Data stock_qty: DF.Float stock_uom: DF.Link | None + type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"] # end: auto-generated types pass diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json index 1a150dc864f..778334b96d0 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json @@ -36,7 +36,7 @@ "capacity_planning_for_days", "mins_between_operations", "other_settings_section", - "set_op_cost_and_scrap_from_sub_assemblies", + "set_op_cost_and_secondary_items_from_sub_assemblies", "column_break_23", "make_serial_no_batch_from_work_order" ], @@ -202,13 +202,6 @@ "fieldtype": "Check", "label": "Validate Components and Quantities Per BOM" }, - { - "default": "0", - "description": "To include sub-assembly costs and scrap items in Finished Goods on a work order without using a job card, when the 'Use Multi-Level BOM' option is enabled.", - "fieldname": "set_op_cost_and_scrap_from_sub_assemblies", - "fieldtype": "Check", - "label": "Set Operating Cost / Scrap Items From Sub-assemblies" - }, { "default": "0", "description": "Enabling this checkbox will force each Job Card Time Log to have From Time and To Time", @@ -237,6 +230,13 @@ "fieldname": "allow_editing_of_items_and_quantities_in_work_order", "fieldtype": "Check", "label": "Allow Editing of Items and Quantities in Work Order" + }, + { + "default": "0", + "description": "To include sub-assembly costs and secondary items in Finished Goods on a work order without using a job card, when the 'Use Multi-Level BOM' option is enabled.", + "fieldname": "set_op_cost_and_secondary_items_from_sub_assemblies", + "fieldtype": "Check", + "label": "Set Operating Cost / Secondary Items From Sub-assemblies" } ], "hide_toolbar": 0, @@ -244,7 +244,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-03-16 13:28:20.714576", + "modified": "2026-03-20 13:28:20.714576", "modified_by": "Administrator", "module": "Manufacturing", "name": "Manufacturing Settings", diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py index e60a9627a21..2913d70395d 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py @@ -32,7 +32,7 @@ class ManufacturingSettings(Document): mins_between_operations: DF.Int overproduction_percentage_for_sales_order: DF.Percent overproduction_percentage_for_work_order: DF.Percent - set_op_cost_and_scrap_from_sub_assemblies: DF.Check + set_op_cost_and_secondary_items_from_sub_assemblies: DF.Check transfer_extra_materials_percentage: DF.Percent update_bom_costs_automatically: DF.Check validate_components_quantities_per_bom: DF.Check diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 4612c427714..5d7e2fa2b36 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -2875,6 +2875,7 @@ def make_bom(**args): "company": args.company or "_Test Company", "routing": args.routing, "with_operations": args.with_operations or 0, + "process_loss_percentage": args.process_loss_percentage or 0, } ) @@ -2896,6 +2897,23 @@ def make_bom(**args): }, ) + if args.scrap_items: + for item in args.scrap_items: + item_doc = frappe.get_doc("Item", item) + bom.append( + "secondary_items", + { + "type": "Scrap", + "item_code": item, + "item_name": item, + "uom": item_doc.stock_uom, + "stock_uom": item_doc.stock_uom, + "qty": args.scrap_qty or 1, + "cost_allocation_per": args.scrap_cost_allocation_per or 10, + "process_loss_per": args.scrap_process_loss_per or 10, + }, + ) + if not args.do_not_save: bom.insert(ignore_permissions=True) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index bea542b7bfa..81ee66ecb4f 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -329,7 +329,7 @@ class TestWorkOrder(ERPNextTestSuite): cint(bin1_on_stop_production.projected_qty) + 1, cint(self.bin1_at_start.projected_qty) ) - def test_scrap_material_qty(self): + def test_secondary_material_qty(self): wo_order = make_wo_order_test_record(planned_start_date=now(), qty=2) # add raw materials to stores @@ -354,15 +354,15 @@ class TestWorkOrder(ERPNextTestSuite): "Work Order", wo_order.name, ["scrap_warehouse", "qty", "produced_qty", "bom_no"], as_dict=1 ) - scrap_item_details = get_scrap_item_details(wo_order_details.bom_no) + secondary_item_details = get_secondary_item_details(wo_order_details.bom_no) self.assertEqual(wo_order_details.produced_qty, 2) for item in s.items: - if item.bom_no and item.item_code in scrap_item_details: + if item.bom_no and item.item_code in secondary_item_details: self.assertEqual(wo_order_details.scrap_warehouse, item.t_warehouse) self.assertEqual( - flt(wo_order_details.qty) * flt(scrap_item_details[item.item_code]), item.qty + flt(wo_order_details.qty) * flt(secondary_item_details[item.item_code]), item.qty ) def test_allow_overproduction(self): @@ -1015,7 +1015,7 @@ class TestWorkOrder(ERPNextTestSuite): self.assertEqual(wo.status, "Completed") @timeout(seconds=60) - def test_job_card_scrap_item(self): + def test_job_card_secondary_item(self): items = [ "Test FG Item for Scrap Item Test", "Test RM Item 1 for Scrap Item Test", @@ -1074,7 +1074,7 @@ class TestWorkOrder(ERPNextTestSuite): stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) for row in stock_entry.items: - if row.is_scrap_item: + if row.type or row.is_legacy_scrap_item: self.assertEqual(row.qty, 1) # Partial Job Card 1 with qty 10 @@ -1086,7 +1086,7 @@ class TestWorkOrder(ERPNextTestSuite): stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) for row in stock_entry.items: - if row.is_scrap_item: + if row.type or row.is_legacy_scrap_item: self.assertEqual(row.qty, 2) # Partial Job Card 2 with qty 10 @@ -2134,10 +2134,12 @@ class TestWorkOrder(ERPNextTestSuite): for row in se_doc.additional_costs: self.assertEqual(row.expense_account, operating_cost_account) - def test_op_cost_and_scrap_based_on_sub_assemblies(self): + def test_set_op_cost_and_secondary_items_from_sub_assemblies(self): # Make Sub Assembly BOM 1 - frappe.db.set_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies", 1) + frappe.db.set_single_value( + "Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies", 1 + ) items = { "Test Final FG Item": 0, @@ -2169,16 +2171,20 @@ class TestWorkOrder(ERPNextTestSuite): se_doc.save() self.assertTrue(se_doc.additional_costs) - scrap_items = [] + secondary_items = [] for item in se_doc.items: - if item.is_scrap_item: - scrap_items.append(item.item_code) + if item.type or item.is_legacy_scrap_item: + secondary_items.append(item.item_code) - self.assertEqual(sorted(scrap_items), sorted(["Test Final Scrap Item 1", "Test Final Scrap Item 2"])) + self.assertEqual( + sorted(secondary_items), sorted(["Test Final Scrap Item 1", "Test Final Scrap Item 2"]) + ) for row in se_doc.additional_costs: self.assertEqual(row.amount, 3000) - frappe.db.set_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies", 0) + frappe.db.set_single_value( + "Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies", 0 + ) @ERPNextTestSuite.change_settings( "Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1} @@ -3951,7 +3957,7 @@ def prepare_boms_for_sub_assembly_test(): do_not_submit=True, ) - bom.append("scrap_items", {"item_code": "Test Final Scrap Item 1", "qty": 1}) + bom.append("secondary_items", {"item_code": "Test Final Scrap Item 1", "qty": 1, "is_legacy": 1}) bom.submit() @@ -3964,7 +3970,7 @@ def prepare_boms_for_sub_assembly_test(): do_not_submit=True, ) - bom.append("scrap_items", {"item_code": "Test Final Scrap Item 2", "qty": 1}) + bom.append("secondary_items", {"item_code": "Test Final Scrap Item 2", "qty": 1, "is_legacy": 1}) bom.submit() @@ -4159,7 +4165,7 @@ def update_job_card(job_card, jc_qty=None, days=None): employee = frappe.db.get_value("Employee", {"status": "Active"}, "name") job_card_doc = frappe.get_doc("Job Card", job_card) job_card_doc.set( - "scrap_items", + "secondary_items", [ {"item_code": "Test RM Item 1 for Scrap Item Test", "stock_qty": 2}, {"item_code": "Test RM Item 2 for Scrap Item Test", "stock_qty": 2}, @@ -4199,17 +4205,17 @@ def update_job_card(job_card, jc_qty=None, days=None): job_card_doc.submit() -def get_scrap_item_details(bom_no): - scrap_items = {} +def get_secondary_item_details(bom_no): + secondary_items = {} for item in frappe.db.sql( - """select item_code, stock_qty from `tabBOM Scrap Item` + """select item_code, stock_qty from `tabBOM Secondary Item` where parent = %s""", bom_no, as_dict=1, ): - scrap_items[item.item_code] = item.stock_qty + secondary_items[item.item_code] = item.stock_qty - return scrap_items + return secondary_items def allow_overproduction(fieldname, percentage): diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index f382d1dcb60..18b5be64c10 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -387,6 +387,7 @@ frappe.ui.form.on("Work Order", { args: { work_order: frm.doc.name, operations: selected_rows, + parent_bom: frm.doc.bom_no, }, callback: function () { frm.reload_doc(); diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index f9d380964bc..72fafa03edd 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -2356,7 +2356,7 @@ def check_if_scrap_warehouse_mandatory(bom_no): if bom_no: bom = frappe.get_doc("BOM", bom_no) - if len(bom.scrap_items) > 0: + if bom.has_scrap_items(): res["set_scrap_wh_mandatory"] = True return res @@ -2420,6 +2420,7 @@ def make_stock_entry( stock_entry.set_stock_entry_type() stock_entry.is_additional_transfer_entry = is_additional_transfer_entry stock_entry.get_items() + stock_entry.set_secondary_items_from_job_card() if purpose != "Disassemble": stock_entry.set_serial_no_batch_for_finished_good() @@ -2478,14 +2479,14 @@ def query_sales_order(doctype, txt, searchfield, start, page_len, filters) -> li @frappe.whitelist() -def make_job_card(work_order, operations): +def make_job_card(work_order: str, operations: str | list, parent_bom: str | None = None): if isinstance(operations, str): operations = json.loads(operations) work_order = frappe.get_doc("Work Order", work_order) for row in operations: row = frappe._dict(row) - row.update(get_operation_details(row.name, work_order)) + row.update(get_operation_details(row.name, work_order, parent_bom)) validate_operation_data(row) qty = row.get("qty") @@ -2495,7 +2496,7 @@ def make_job_card(work_order, operations): create_job_card(work_order, row, auto_create=True) -def get_operation_details(name, work_order): +def get_operation_details(name, work_order, parent_bom): for row in work_order.operations: if row.name == name: return { @@ -2505,7 +2506,7 @@ def get_operation_details(name, work_order): "fg_warehouse": row.fg_warehouse, "wip_warehouse": row.wip_warehouse, "finished_good": row.finished_good, - "bom_no": row.get("bom_no"), + "bom_no": row.get("bom_no") or parent_bom, "is_subcontracted": row.get("is_subcontracted"), } @@ -2640,8 +2641,9 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create work_order.transfer_material_against == "Job Card" and not work_order.skip_transfer ): doc.get_required_items() - if work_order.track_semi_finished_goods: - doc.set_scrap_items() + + if work_order.track_semi_finished_goods: + doc.set_secondary_items() if auto_create: doc.flags.ignore_mandatory = True diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 4b1fc449473..8e36eaaed40 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -472,3 +472,4 @@ erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po erpnext.patches.v16_0.enable_serial_batch_setting erpnext.patches.v16_0.update_requested_qty_packed_item erpnext.patches.v16_0.remove_payables_receivables_workspace +erpnext.patches.v16_0.co_by_product_patch diff --git a/erpnext/patches/v16_0/co_by_product_patch.py b/erpnext/patches/v16_0/co_by_product_patch.py new file mode 100644 index 00000000000..63f43e85b9e --- /dev/null +++ b/erpnext/patches/v16_0/co_by_product_patch.py @@ -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") diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index f0be33b6a87..4971f914b1e 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1855,7 +1855,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe "base_operating_cost", "base_raw_material_cost", "base_total_cost", - "base_scrap_material_cost", + "base_secondary_items_cost", "base_totals_section", ], company_currency @@ -1873,7 +1873,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe "paid_amount", "write_off_amount", "operating_cost", - "scrap_material_cost", + "secondary_items_cost", "raw_material_cost", "total_cost", "totals_section", @@ -1919,7 +1919,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe "base_operating_cost", "base_raw_material_cost", "base_total_cost", - "base_scrap_material_cost", + "base_secondary_items_cost", "base_rounding_adjustment", ], this.frm.doc.currency != company_currency @@ -1984,11 +1984,11 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }); } - if (this.frm.doc.scrap_items && this.frm.doc.scrap_items.length > 0) { - this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "scrap_items"); - this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "scrap_items"); + if (this.frm.doc.secondary_items && this.frm.doc.secondary_items.length > 0) { + this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "secondary_items"); + this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "secondary_items"); - var item_grid = this.frm.fields_dict["scrap_items"].grid; + var item_grid = this.frm.fields_dict["secondary_items"].grid; $.each(["base_rate", "base_amount"], function (i, fname) { if (frappe.meta.get_docfield(item_grid.doctype, fname)) item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency); diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index b7896b58dff..d501f8abd51 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -49,7 +49,7 @@ "section_break_zwh6", "allow_delivery_of_overproduced_qty", "column_break_mla9", - "deliver_scrap_items" + "deliver_secondary_items" ], "fields": [ { @@ -260,13 +260,6 @@ "fieldname": "column_break_mla9", "fieldtype": "Column Break" }, - { - "default": "0", - "description": "If enabled, the Scrap Item generated against a Finished Good will also be added in the Stock Entry when delivering that Finished Good.", - "fieldname": "deliver_scrap_items", - "fieldtype": "Check", - "label": "Deliver Scrap Items" - }, { "fieldname": "item_price_tab", "fieldtype": "Tab Break", @@ -320,6 +313,13 @@ "fieldname": "enable_utm", "fieldtype": "Check", "label": "Enable UTM" + }, + { + "default": "0", + "description": "If enabled, the Secondary Items generated against a Finished Good will also be added in the Stock Entry when delivering that Finished Good.", + "fieldname": "deliver_secondary_items", + "fieldtype": "Check", + "label": "Deliver Secondary Items" } ], "grid_page_length": 50, diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.py b/erpnext/selling/doctype/selling_settings/selling_settings.py index 8621f5f066d..c13d4ce0a6c 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.py +++ b/erpnext/selling/doctype/selling_settings/selling_settings.py @@ -41,7 +41,7 @@ class SellingSettings(Document): blanket_order_allowance: DF.Float cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"] customer_group: DF.Link | None - deliver_scrap_items: DF.Check + deliver_secondary_items: DF.Check dn_required: DF.Literal["No", "Yes"] dont_reserve_sales_order_qty_on_sales_return: DF.Check editable_bundle_item_rates: DF.Check diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 8111935a339..51eb71d6f79 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -820,7 +820,7 @@ class Company(NestedSet): boms = frappe.db.sql_list("select name from tabBOM where company=%s", self.name) if boms: frappe.db.sql("delete from tabBOM where company=%s", self.name) - for dt in ("BOM Operation", "BOM Item", "BOM Scrap Item", "BOM Explosion Item"): + for dt in ("BOM Operation", "BOM Item", "BOM Secondary Item", "BOM Explosion Item"): frappe.db.sql( "delete from `tab{}` where parent in ({})".format(dt, ", ".join(["%s"] * len(boms))), tuple(boms), diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index f71b67e1127..dbfad27be26 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -1334,13 +1334,13 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle } fg_completed_qty() { - this.get_items(); + if (!this.frm.doc.job_card) { + this.get_items(); + } } get_items() { var me = this; - if (!this.frm.doc.fg_completed_qty || !this.frm.doc.bom_no) - frappe.throw(__("BOM and Manufacturing Quantity are required")); if (this.frm.doc.work_order || this.frm.doc.bom_no) { // if work order / bom is mentioned, get items diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 4ce2bda3631..19c00ceacea 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -31,7 +31,7 @@ from erpnext.manufacturing.doctype.bom.bom import ( add_additional_cost, get_bom_items_as_dict, get_op_cost_from_sub_assemblies, - get_scrap_items_from_sub_assemblies, + get_secondary_items_from_sub_assemblies, validate_bom_no, ) from erpnext.setup.doctype.brand.brand import get_brand_defaults @@ -245,7 +245,7 @@ class StockEntry(StockController, SubcontractingInwardController): self.validate_company_in_accounting_dimension() if self.purpose in ("Manufacture", "Repack"): - self.mark_finished_and_scrap_items() + self.mark_finished_and_secondary_items() if not self.job_card: self.validate_finished_goods() else: @@ -272,7 +272,7 @@ class StockEntry(StockController, SubcontractingInwardController): self.validate_component_and_quantities() if self.get("purpose") != "Manufacture": - # ignore scrap item wh difference and empty source/target wh + # ignore other item wh difference and empty source/target wh # in Manufacture Entry self.reset_default_field_value("from_warehouse", "items", "s_warehouse") self.reset_default_field_value("to_warehouse", "items", "t_warehouse") @@ -656,7 +656,7 @@ class StockEntry(StockController, SubcontractingInwardController): item.expense_account = frappe.get_value("Company", self.company, "default_expense_account") def validate_fg_completed_qty(self): - if self.purpose != "Manufacture": + if self.purpose != "Manufacture" or not self.from_bom: return fg_qty = defaultdict(float) @@ -789,7 +789,7 @@ class StockEntry(StockController, SubcontractingInwardController): if self.purpose == "Manufacture": if has_bom: - if d.is_finished_item or d.is_scrap_item: + if d.is_finished_item or d.type or d.is_legacy_scrap_item: d.s_warehouse = None if not d.t_warehouse: frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx)) @@ -1093,11 +1093,10 @@ class StockEntry(StockController, SubcontractingInwardController): def set_basic_rate(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): """ - Set rate for outgoing, scrapped and finished items + Set rate for outgoing, secondary and finished items """ # Set rate for outgoing items outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate) - finished_item_qty = sum(d.transfer_qty for d in self.items if d.is_finished_item) items = [] # Set basic rate for incoming items @@ -1111,11 +1110,19 @@ class StockEntry(StockController, SubcontractingInwardController): elif d.is_finished_item: if self.purpose == "Manufacture": d.basic_rate = self.get_basic_rate_for_manufactured_item( - finished_item_qty, outgoing_items_cost + d.transfer_qty, outgoing_items_cost ) elif self.purpose == "Repack": d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost) + if self.bom_no: + d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100 + elif d.type and d.bom_secondary_item: + cost_allocation_per = frappe.get_value( + "BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per" + ) + d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty + if not d.basic_rate and not d.allow_zero_valuation_rate: if self.is_new(): raise_error_if_no_rate = False @@ -1198,7 +1205,7 @@ class StockEntry(StockController, SubcontractingInwardController): def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float: settings = frappe.get_single("Manufacturing Settings") - scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_scrap_item]) + scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_legacy_scrap_item]) if settings.material_consumption: if settings.get_rm_cost_from_consumption_entry and self.work_order: @@ -1212,7 +1219,7 @@ class StockEntry(StockController, SubcontractingInwardController): }, ): for item in self.items: - if not item.is_finished_item and not item.is_scrap_item: + if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item: label = frappe.get_meta(settings.doctype).get_label( "get_rm_cost_from_consumption_entry" ) @@ -1614,7 +1621,7 @@ class StockEntry(StockController, SubcontractingInwardController): order, ) - def mark_finished_and_scrap_items(self): + def mark_finished_and_secondary_items(self): if self.purpose != "Repack" and any( [d.item_code for d in self.items if (d.is_finished_item and d.t_warehouse)] ): @@ -1631,11 +1638,9 @@ class StockEntry(StockController, SubcontractingInwardController): if d.t_warehouse and not d.s_warehouse: if self.purpose == "Repack" or d.item_code == finished_item: d.is_finished_item = 1 - else: - d.is_scrap_item = 1 else: d.is_finished_item = 0 - d.is_scrap_item = 0 + d.type = "" def get_finished_item(self): finished_item = None @@ -2434,7 +2439,7 @@ class StockEntry(StockController, SubcontractingInwardController): self.load_items_from_bom() self.set_serial_batch_from_reserved_entry() - self.set_scrap_items() + self.set_secondary_items() self.set_actual_qty() self.validate_customer_provided_item() self.calculate_rate_and_amount(raise_error_if_no_rate=False) @@ -2579,14 +2584,21 @@ class StockEntry(StockController, SubcontractingInwardController): return query.run(as_dict=True) - def set_scrap_items(self): - if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]: - scrap_item_dict = self.get_bom_scrap_material(self.fg_completed_qty) - for item in scrap_item_dict.values(): - if self.pro_doc and self.pro_doc.scrap_warehouse: - item["to_warehouse"] = self.pro_doc.scrap_warehouse + def set_secondary_items(self): + if self.purpose in ["Manufacture", "Repack"]: + secondary_items_dict = self.get_secondary_items(self.fg_completed_qty) + for item in secondary_items_dict.values(): + if self.pro_doc and item.type: + if self.pro_doc.scrap_warehouse and item.type == "Scrap": + item["to_warehouse"] = self.pro_doc.scrap_warehouse - self.add_to_stock_entry_detail(scrap_item_dict, bom_no=self.bom_no) + if item.process_loss_per: + item["qty"] -= flt( + item["qty"] * (item.process_loss_per / 100), + self.precision("fg_completed_qty"), + ) + + self.add_to_stock_entry_detail(secondary_items_dict, bom_no=self.bom_no) def set_process_loss_qty(self): if self.purpose not in ("Manufacture", "Repack"): @@ -2600,7 +2612,7 @@ class StockEntry(StockController, SubcontractingInwardController): fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}], ) - if data and data[0].process_loss_qty is not None: + if data and data[0].process_loss_qty: process_loss_qty = data[0].process_loss_qty if flt(self.process_loss_qty, precision) != flt(process_loss_qty, precision): self.process_loss_qty = flt(process_loss_qty, precision) @@ -2632,7 +2644,7 @@ class StockEntry(StockController, SubcontractingInwardController): if not self.pro_doc: self.pro_doc = frappe.get_doc("Work Order", self.work_order) - if self.pro_doc: + if self.pro_doc and not self.pro_doc.track_semi_finished_goods: self.bom_no = self.pro_doc.bom_no else: # invalid work order @@ -2774,54 +2786,59 @@ class StockEntry(StockController, SubcontractingInwardController): return item_dict - def get_bom_scrap_material(self, qty): + def get_secondary_items(self, qty): from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict if ( - frappe.db.get_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies") + frappe.db.get_single_value( + "Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies" + ) and self.work_order and frappe.get_cached_value("Work Order", self.work_order, "use_multi_level_bom") ): - item_dict = get_scrap_items_from_sub_assemblies(self.bom_no, self.company, qty) + item_dict = get_secondary_items_from_sub_assemblies(self.bom_no, self.company, qty) else: # item dict = { item_code: {qty, description, stock_uom} } item_dict = ( get_bom_items_as_dict( - self.bom_no, self.company, qty=qty, fetch_exploded=0, fetch_scrap_items=1 + self.bom_no, self.company, qty=qty, fetch_exploded=0, fetch_secondary_items=1 ) or {} ) for item in item_dict.values(): item.from_warehouse = "" - item.is_scrap_item = 1 - - for row in self.get_scrap_items_from_job_card(): - if row.stock_qty <= 0: - continue - - item_row = item_dict.get(row.item_code) - if not item_row: - item_row = frappe._dict({}) - - item_row.update( - { - "uom": row.stock_uom, - "from_warehouse": "", - "qty": row.stock_qty + flt(item_row.stock_qty), - "converison_factor": 1, - "is_scrap_item": 1, - "item_name": row.item_name, - "description": row.description, - "allow_zero_valuation_rate": 1, - } - ) - - item_dict[row.item_code] = item_row return item_dict - def get_scrap_items_from_job_card(self): + def set_secondary_items_from_job_card(self): + if self.purpose not in ["Manufacture", "Repack"]: + return + + item_dict = {} + for row in self.get_secondary_items_from_job_card(): + if row.stock_qty <= 0: + continue + + item_dict[row.item_code] = frappe._dict( + { + "uom": row.stock_uom, + "from_warehouse": "", + "qty": row.stock_qty, + "conversion_factor": 1, + "type": row.type, + "item_name": row.item_name, + "description": row.description, + "bom_secondary_item": row.bom_secondary_item, + } + ) + + for item in item_dict.values(): + item.from_warehouse = "" + + self.add_to_stock_entry_detail(item_dict) + + def get_secondary_items_from_job_card(self): if not hasattr(self, "pro_doc"): self.pro_doc = None @@ -2832,70 +2849,78 @@ class StockEntry(StockController, SubcontractingInwardController): return [] job_card = frappe.qb.DocType("Job Card") - job_card_scrap_item = frappe.qb.DocType("Job Card Scrap Item") + job_card_secondary_item = frappe.qb.DocType("Job Card Secondary Item") - scrap_items = ( + other = ( frappe.qb.from_(job_card) .select( - Sum(job_card_scrap_item.stock_qty).as_("stock_qty"), - job_card_scrap_item.item_code, - job_card_scrap_item.item_name, - job_card_scrap_item.description, - job_card_scrap_item.stock_uom, + Sum(job_card_secondary_item.stock_qty).as_("stock_qty"), + job_card_secondary_item.item_code, + job_card_secondary_item.item_name, + job_card_secondary_item.description, + job_card_secondary_item.stock_uom, + job_card_secondary_item.type, + job_card_secondary_item.bom_secondary_item, ) - .join(job_card_scrap_item) - .on(job_card_scrap_item.parent == job_card.name) + .join(job_card_secondary_item) + .on(job_card_secondary_item.parent == job_card.name) .where( - (job_card_scrap_item.item_code.isnotnull()) + (job_card_secondary_item.item_code.isnotnull()) & (job_card.work_order == self.work_order) & (job_card.docstatus == 1) ) - .groupby(job_card_scrap_item.item_code) + .groupby(job_card_secondary_item.item_code, job_card_secondary_item.type) + .orderby(job_card_secondary_item.idx) ) if self.job_card: - scrap_items = scrap_items.where(job_card.name == self.job_card) + other = other.where(job_card.name == self.job_card) - scrap_items = scrap_items.run(as_dict=1) + other = other.run(as_dict=1) if self.job_card: pending_qty = flt(self.fg_completed_qty) else: pending_qty = flt(self.get_completed_job_card_qty()) - flt(self.pro_doc.produced_qty) - used_scrap_items = self.get_used_scrap_items() - for row in scrap_items: - row.stock_qty -= flt(used_scrap_items.get(row.item_code)) + used_secondary_items = self.get_used_secondary_items() + for row in other: + row.stock_qty -= flt(used_secondary_items.get(row.item_code)) row.stock_qty = (row.stock_qty) * flt(self.fg_completed_qty) / flt(pending_qty) - if used_scrap_items.get(row.item_code): - used_scrap_items[row.item_code] -= row.stock_qty + if used_secondary_items.get(row.item_code): + used_secondary_items[row.item_code] -= row.stock_qty if cint(frappe.get_cached_value("UOM", row.stock_uom, "must_be_whole_number")): row.stock_qty = frappe.utils.ceil(row.stock_qty) - return scrap_items + return other def get_completed_job_card_qty(self): return flt(min([d.completed_qty for d in self.pro_doc.operations])) - def get_used_scrap_items(self): - used_scrap_items = defaultdict(float) - data = frappe.get_all( - "Stock Entry", - fields=["`tabStock Entry Detail`.`item_code`", "`tabStock Entry Detail`.`qty`"], - filters=[ - ["Stock Entry", "work_order", "=", self.work_order], - ["Stock Entry Detail", "is_scrap_item", "=", 1], - ["Stock Entry", "docstatus", "=", 1], - ["Stock Entry", "purpose", "in", ["Repack", "Manufacture"]], - ], - ) + def get_used_secondary_items(self): + used_secondary_items = defaultdict(float) + + StockEntry = frappe.qb.DocType("Stock Entry") + StockEntryDetail = frappe.qb.DocType("Stock Entry Detail") + data = ( + frappe.qb.from_(StockEntry) + .inner_join(StockEntryDetail) + .on(StockEntryDetail.parent == StockEntry.name) + .select(StockEntryDetail.item_code, StockEntryDetail.qty) + .where( + (StockEntry.work_order == self.work_order) + & ((StockEntryDetail.type.isnotnull()) | (StockEntryDetail.is_legacy_scrap_item == 1)) + & (StockEntry.docstatus == 1) + & (StockEntry.purpose.isin(["Repack", "Manufacture"])) + ) + ).run(as_dict=1) for row in data: - used_scrap_items[row.item_code] += row.qty + used_secondary_items[row.item_code] += row.qty - return used_scrap_items + return used_secondary_items def get_unconsumed_raw_materials(self): wo = frappe.get_doc("Work Order", self.work_order) @@ -3187,7 +3212,12 @@ class StockEntry(StockController, SubcontractingInwardController): item_row = item_dict[d] child_qty = flt(item_row["qty"], precision) - if not self.is_return and child_qty <= 0 and not item_row.get("is_scrap_item"): + if ( + not self.is_return + and child_qty <= 0 + and not item_row.get("type") + and not item_row.get("is_legacy_scrap_item") + ): if self.purpose not in ["Receive from Customer", "Send to Subcontractor"]: continue @@ -3205,11 +3235,13 @@ class StockEntry(StockController, SubcontractingInwardController): item_row, company=self.company ) se_child.is_finished_item = item_row.get("is_finished_item", 0) - se_child.is_scrap_item = item_row.get("is_scrap_item", 0) se_child.po_detail = item_row.get("po_detail") se_child.sco_rm_detail = item_row.get("sco_rm_detail") se_child.scio_detail = item_row.get("scio_detail") se_child.sample_quantity = item_row.get("sample_quantity", 0) + se_child.type = item_row.get("type") + se_child.is_legacy_scrap_item = item_row.get("is_legacy") + se_child.bom_secondary_item = item_row.get("name") or item_row.get("bom_secondary_item") for field in [ self.subcontract_data.rm_detail_field, @@ -3686,7 +3718,7 @@ def get_operating_cost_per_unit(work_order=None, bom_no=None): if ( bom_no and frappe.db.get_single_value( - "Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies" + "Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies" ) and frappe.get_cached_value("Work Order", work_order.name, "use_multi_level_bom") ): diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 48488a7c5b6..b102e20cfc4 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -909,8 +909,8 @@ class TestStockEntry(ERPNextTestSuite): if d.s_warehouse: rm_cost += d.amount fg_cost = next(filter(lambda x: x.item_code == "_Test FG Item", s.get("items"))).amount - scrap_cost = next(filter(lambda x: x.is_scrap_item, s.get("items"))).amount - self.assertEqual(fg_cost, flt(rm_cost - scrap_cost, 2)) + secondary_item_cost = next(filter(lambda x: x.type or x.is_legacy_scrap_item, s.get("items"))).amount + self.assertEqual(fg_cost, flt(rm_cost - secondary_item_cost, 2)) # When Stock Entry has only FG + Scrap s.items.pop(0) @@ -989,15 +989,15 @@ class TestStockEntry(ERPNextTestSuite): self.assertRaises(frappe.ValidationError, ste.submit) - def test_quality_check_for_scrap_item(self): + def test_quality_check_for_secondary_item(self): from erpnext.manufacturing.doctype.work_order.work_order import ( make_stock_entry as _make_stock_entry, ) - scrap_item = "_Test Scrap Item 1" - make_item(scrap_item, {"is_stock_item": 1, "is_purchase_item": 0}) + secondary_item = "_Test Scrap Item 1" + make_item(secondary_item, {"is_stock_item": 1, "is_purchase_item": 0}) - bom_name = frappe.db.get_value("BOM Scrap Item", {"docstatus": 1}, "parent") + bom_name = frappe.db.get_value("BOM Secondary Item", {"docstatus": 1}, "parent") production_item = frappe.db.get_value("BOM", bom_name, "item") work_order = frappe.new_doc("Work Order") @@ -1027,18 +1027,18 @@ class TestStockEntry(ERPNextTestSuite): basic_rate=row.basic_rate or 100, ) - if row.is_scrap_item: - row.item_code = scrap_item - row.uom = frappe.db.get_value("Item", scrap_item, "stock_uom") - row.stock_uom = frappe.db.get_value("Item", scrap_item, "stock_uom") + if row.type or row.is_legacy_scrap_item: + row.item_code = secondary_item + row.uom = frappe.db.get_value("Item", secondary_item, "stock_uom") + row.stock_uom = frappe.db.get_value("Item", secondary_item, "stock_uom") stock_entry.inspection_required = 1 stock_entry.save() - self.assertTrue([row.item_code for row in stock_entry.items if row.is_scrap_item]) + self.assertTrue([row.item_code for row in stock_entry.items if row.type or row.is_legacy_scrap_item]) for row in stock_entry.items: - if not row.is_scrap_item: + if not row.type and not row.is_legacy_scrap_item: qc = frappe.get_doc( { "doctype": "Quality Inspection", @@ -1058,7 +1058,7 @@ class TestStockEntry(ERPNextTestSuite): stock_entry.reload() stock_entry.submit() for row in stock_entry.items: - if row.is_scrap_item: + if row.type or row.is_legacy_scrap_item: self.assertFalse(row.quality_inspection) else: self.assertTrue(row.quality_inspection) @@ -2464,6 +2464,35 @@ class TestStockEntry(ERPNextTestSuite): # delete naming rule frappe.delete_doc("Document Naming Rule", qc_naming_rule.name) + def test_co_by_product(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + frappe.set_value("UOM", "Nos", "must_be_whole_number", 0) + + fg_item = make_item("FG Item", properties={"is_stock_item": 1}).name + rm_item = make_item("RM Item", properties={"is_stock_item": 1}).name + scrap_item = make_item("Scrap Item", properties={"is_stock_item": 1}).name + warehouse = "_Test Warehouse - _TC" + make_stock_entry(item_code=rm_item, target=warehouse, qty=5, rate=10, purpose="Material Receipt") + + bom_no = make_bom( + item=fg_item, raw_materials=[rm_item], scrap_items=[scrap_item], process_loss_percentage=10 + ).name + se = make_stock_entry(item_code=fg_item, qty=5, purpose="Manufacture", do_not_save=True) + se.from_bom = 1 + se.bom_no = bom_no + se.fg_completed_qty = 5 + se.from_warehouse = warehouse + se.to_warehouse = "_Test Warehouse 1 - _TC" + se.get_items() + se.save() + se.reload() + + self.assertEqual(se.items[1].qty, 4.5) + self.assertEqual(se.items[1].amount, 45) + self.assertEqual(se.items[2].qty, 4.5) + self.assertEqual(se.items[2].amount, 5) + def make_serialized_item(self, **args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index eceba634bf3..f28f5e25a66 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -18,7 +18,8 @@ "item_name", "col_break2", "is_finished_item", - "is_scrap_item", + "is_legacy_scrap_item", + "type", "quality_inspection", "subcontracted_item", "against_fg", @@ -81,7 +82,8 @@ "putaway_rule", "column_break_51", "reference_purchase_receipt", - "job_card_item" + "job_card_item", + "bom_secondary_item" ], "fields": [ { @@ -558,12 +560,7 @@ }, { "default": "0", - "fieldname": "is_scrap_item", - "fieldtype": "Check", - "label": "Is Scrap Item" - }, - { - "default": "0", + "depends_on": "eval:!doc.is_legacy_scrap_item && !doc.type", "fieldname": "is_finished_item", "fieldtype": "Check", "label": "Is Finished Item", @@ -654,6 +651,28 @@ "no_copy": 1, "options": "Subcontracting Inward Order Item", "set_only_once": 1 + }, + { + "depends_on": "eval:parent.purpose == \"Manufacture\" && doc.t_warehouse && !doc.is_finished_item && !doc.is_legacy_scrap_item", + "fieldname": "type", + "fieldtype": "Select", + "label": "Type", + "options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good" + }, + { + "fieldname": "bom_secondary_item", + "fieldtype": "Data", + "hidden": 1, + "label": "BOM Secondary Item", + "read_only": 1 + }, + { + "default": "0", + "depends_on": "is_legacy_scrap_item", + "fieldname": "is_legacy_scrap_item", + "fieldtype": "Check", + "label": "Is Legacy Scrap Item", + "read_only": 1 } ], "grid_page_length": 50, diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py index 95bb7181a0f..0c1a21fefce 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py @@ -26,6 +26,7 @@ class StockEntryDetail(Document): basic_rate: DF.Currency batch_no: DF.Link | None bom_no: DF.Link | None + bom_secondary_item: DF.Data | None conversion_factor: DF.Float cost_center: DF.Link | None customer_provided_item_cost: DF.Currency @@ -34,7 +35,7 @@ class StockEntryDetail(Document): has_item_scanned: DF.Check image: DF.Attach | None is_finished_item: DF.Check - is_scrap_item: DF.Check + is_legacy_scrap_item: DF.Check item_code: DF.Link item_group: DF.Data | None item_name: DF.Data | None @@ -66,6 +67,7 @@ class StockEntryDetail(Document): t_warehouse: DF.Link | None transfer_qty: DF.Float transferred_qty: DF.Float + type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"] uom: DF.Link use_serial_batch_fields: DF.Check valuation_rate: DF.Currency diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py index 4a768ee94fd..f02c06810f0 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py @@ -75,13 +75,18 @@ class ManufactureEntry: self.stock_entry = frappe.new_doc("Stock Entry") self.stock_entry.purpose = self.purpose self.stock_entry.company = self.company - self.stock_entry.from_bom = 1 - self.stock_entry.bom_no = self.bom_no - self.stock_entry.use_multi_level_bom = 1 + + if self.bom_no: + self.stock_entry.from_bom = 1 + self.stock_entry.bom_no = self.bom_no + self.stock_entry.use_multi_level_bom = 1 + self.stock_entry.fg_completed_qty = self.for_quantity + self.stock_entry.process_loss_qty = self.process_loss_qty self.stock_entry.project = self.project self.stock_entry.job_card = self.job_card self.stock_entry.set_stock_entry_type() + self.stock_entry.work_order = self.work_order self.prepare_source_warehouse() self.add_raw_materials() @@ -303,7 +308,7 @@ class ManufactureEntry: args = { "to_warehouse": self.fg_warehouse, "from_warehouse": "", - "qty": self.for_quantity, + "qty": self.for_quantity - self.process_loss_qty, "item_name": item.item_name, "description": item.description, "stock_uom": item.stock_uom, diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.json b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.json index 95ac21ac71b..a0b163f4271 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.json +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.json @@ -25,7 +25,7 @@ "raw_materials_received_section", "received_items", "scrap_items_generated_section", - "scrap_items", + "secondary_items", "service_items_section", "service_items", "tab_other_info", @@ -252,17 +252,10 @@ "reqd": 1 }, { - "depends_on": "scrap_items", + "depends_on": "secondary_items", "fieldname": "scrap_items_generated_section", "fieldtype": "Section Break", - "label": "Scrap Items Generated" - }, - { - "fieldname": "scrap_items", - "fieldtype": "Table", - "label": "Scrap Items", - "no_copy": 1, - "options": "Subcontracting Inward Order Scrap Item" + "label": "Secondary Items Generated" }, { "fieldname": "per_returned", @@ -300,13 +293,20 @@ "label": "Customer Currency", "options": "Currency", "read_only": 1 + }, + { + "fieldname": "secondary_items", + "fieldtype": "Table", + "label": "Secondary Items", + "no_copy": 1, + "options": "Subcontracting Inward Order Secondary Item" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-12-09 15:52:55.781346", + "modified": "2026-02-26 17:16:21.697846", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Inward Order", diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py index b516518bfcb..aea08e18b34 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py @@ -25,8 +25,8 @@ class SubcontractingInwardOrder(SubcontractingController): from erpnext.subcontracting.doctype.subcontracting_inward_order_received_item.subcontracting_inward_order_received_item import ( SubcontractingInwardOrderReceivedItem, ) - from erpnext.subcontracting.doctype.subcontracting_inward_order_scrap_item.subcontracting_inward_order_scrap_item import ( - SubcontractingInwardOrderScrapItem, + from erpnext.subcontracting.doctype.subcontracting_inward_order_secondary_item.subcontracting_inward_order_secondary_item import ( + SubcontractingInwardOrderSecondaryItem, ) from erpnext.subcontracting.doctype.subcontracting_inward_order_service_item.subcontracting_inward_order_service_item import ( SubcontractingInwardOrderServiceItem, @@ -48,7 +48,7 @@ class SubcontractingInwardOrder(SubcontractingController): per_returned: DF.Percent received_items: DF.Table[SubcontractingInwardOrderReceivedItem] sales_order: DF.Link - scrap_items: DF.Table[SubcontractingInwardOrderScrapItem] + secondary_items: DF.Table[SubcontractingInwardOrderSecondaryItem] service_items: DF.Table[SubcontractingInwardOrderServiceItem] set_delivery_warehouse: DF.Link | None status: DF.Literal[ @@ -474,23 +474,25 @@ class SubcontractingInwardOrder(SubcontractingController): stock_entry.add_to_stock_entry_detail(items_dict) if ( - frappe.get_single_value("Selling Settings", "deliver_scrap_items") - and self.scrap_items + frappe.get_single_value("Selling Settings", "deliver_secondary_items") + and self.secondary_items and scio_details ): - scrap_items = [ - scrap_item for scrap_item in self.scrap_items if scrap_item.reference_name in scio_details + secondary_items = [ + secondary_item + for secondary_item in self.secondary_items + if secondary_item.reference_name in scio_details ] - for scrap_item in scrap_items: - qty = scrap_item.produced_qty - scrap_item.delivered_qty + for secondary_item in secondary_items: + qty = secondary_item.produced_qty - secondary_item.delivered_qty if qty > 0: items_dict = { - scrap_item.item_code: { - "qty": scrap_item.produced_qty - scrap_item.delivered_qty, - "from_warehouse": scrap_item.warehouse, - "stock_uom": scrap_item.stock_uom, - "scio_detail": scrap_item.name, - "is_scrap_item": 1, + secondary_item.item_code: { + "qty": secondary_item.produced_qty - secondary_item.delivered_qty, + "from_warehouse": secondary_item.warehouse, + "stock_uom": secondary_item.stock_uom, + "scio_detail": secondary_item.name, + "type": secondary_item.type, } } diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py index 9463b11bf4c..d035f4ddcb9 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py @@ -323,10 +323,12 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite): delivery.items[0].qty = 6 self.assertRaises(frappe.ValidationError, delivery.submit) - @ERPNextTestSuite.change_settings("Selling Settings", {"deliver_scrap_items": 1}) + @ERPNextTestSuite.change_settings("Selling Settings", {"deliver_secondary_items": 1}) def test_secondary_items_delivery(self): new_bom = frappe.copy_doc(frappe.get_doc("BOM", "BOM-Basic FG Item-001")) - new_bom.scrap_items.append(frappe.new_doc("BOM Scrap Item", item_code="Basic RM 2", qty=1)) + new_bom.secondary_items.append( + frappe.new_doc("BOM Secondary Item", item_code="Basic RM 2", qty=1, type="Scrap") + ) new_bom.submit() sc_bom = frappe.get_doc("Subcontracting BOM", "SB-0001") sc_bom.finished_good_bom = new_bom.name @@ -343,12 +345,12 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite): frappe.new_doc("Stock Entry").update(make_stock_entry_from_wo(wo.name, "Manufacture")).submit() scio.reload() - self.assertEqual(scio.scrap_items[0].item_code, "Basic RM 2") + self.assertEqual(scio.secondary_items[0].item_code, "Basic RM 2") delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()) self.assertEqual(delivery.items[-1].item_code, "Basic RM 2") - frappe.db.set_single_value("Selling Settings", "deliver_scrap_items", 0) + frappe.db.set_single_value("Selling Settings", "deliver_secondary_items", 0) delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()) self.assertNotEqual(delivery.items[-1].item_code, "Basic RM 2") diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/__init__.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/__init__.py similarity index 100% rename from erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/__init__.py rename to erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/__init__.py diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/subcontracting_inward_order_scrap_item.json b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.json similarity index 83% rename from erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/subcontracting_inward_order_scrap_item.json rename to erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.json index 78902701532..94a640b41ce 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/subcontracting_inward_order_scrap_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.json @@ -6,13 +6,15 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "column_break_rptg", + "type", + "reference_name", + "column_break_jkzt", "item_code", "fg_item_code", "column_break_hoxe", "stock_uom", "warehouse", - "column_break_rptg", - "reference_name", "section_break_gqk9", "produced_qty", "column_break_n4xc", @@ -93,16 +95,29 @@ { "fieldname": "column_break_n4xc", "fieldtype": "Column Break" + }, + { + "fieldname": "type", + "fieldtype": "Select", + "label": "Type", + "no_copy": 1, + "options": "Co-Product\nBy-Product\nScrap\nAdditional Finished Good", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_jkzt", + "fieldtype": "Column Break" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-14 10:28:30.192350", + "modified": "2026-02-27 15:15:40.009957", "modified_by": "Administrator", "module": "Subcontracting", - "name": "Subcontracting Inward Order Scrap Item", + "name": "Subcontracting Inward Order Secondary Item", "owner": "Administrator", "permissions": [], "row_format": "Dynamic", diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/subcontracting_inward_order_scrap_item.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.py similarity index 81% rename from erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/subcontracting_inward_order_scrap_item.py rename to erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.py index d7aaae229dd..767f216921a 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order_scrap_item/subcontracting_inward_order_scrap_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.py @@ -5,7 +5,7 @@ from frappe.model.document import Document -class SubcontractingInwardOrderScrapItem(Document): +class SubcontractingInwardOrderSecondaryItem(Document): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -23,6 +23,7 @@ class SubcontractingInwardOrderScrapItem(Document): produced_qty: DF.Float reference_name: DF.Data stock_uom: DF.Link + type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"] warehouse: DF.Link # end: auto-generated types diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index 1e05afa2fbf..40de8eb39d4 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -439,6 +439,13 @@ def get_mapped_subcontracting_receipt(source_name, target_doc=None, items=None): target.purchase_order = source_parent.purchase_order target.purchase_order_item = source.purchase_order_item target.qty = items.get(source.name) or (flt(source.qty) - flt(source.received_qty)) + target.received_qty = target.qty + if process_loss_per := frappe.get_value("BOM", source.bom, "process_loss_percentage"): + target.process_loss_qty = flt( + target.qty * (process_loss_per / 100), target.precision("process_loss_qty") + ) + target.qty -= target.process_loss_qty + target.amount = (flt(source.qty) - flt(source.received_qty)) * flt(source.rate) items = {item["name"]: item["qty"] for item in items} if items else {} diff --git a/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json b/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json index 689b64492f5..44ec2185ce6 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json @@ -425,7 +425,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-11-03 12:29:45.156101", + "modified": "2026-02-27 23:03:36.436504", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Order Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index 3339cff689c..5bb7c2f0cc2 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -174,6 +174,7 @@ frappe.ui.form.on("Subcontracting Receipt", { frm.trigger("setup_quality_inspection"); frm.trigger("set_route_options_for_new_doc"); + frm.set_df_property("items", "cannot_add_rows", true); }, set_warehouse: (frm) => { @@ -184,15 +185,15 @@ frappe.ui.form.on("Subcontracting Receipt", { set_warehouse_in_children(frm.doc.items, "rejected_warehouse", frm.doc.rejected_warehouse); }, - get_scrap_items: (frm) => { + get_secondary_items: (frm) => { frappe.call({ doc: frm.doc, - method: "get_scrap_items", + method: "get_secondary_items", args: { recalculate_rate: true, }, freeze: true, - freeze_message: __("Getting Scrap Items"), + freeze_message: __("Getting Secondary Items"), callback: (r) => { if (!r.exc) { frm.refresh(); @@ -422,11 +423,19 @@ frappe.ui.form.on("Subcontracting Receipt Item", { set_missing_values(frm); }, + rejected_qty(frm) { + set_missing_values(frm); + }, + + process_loss_qty(frm) { + set_missing_values(frm); + }, + rate(frm) { set_missing_values(frm); }, - items_delete: (frm) => { + items_delete(frm) { set_missing_values(frm); }, diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.json b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.json index 79b46ec146a..a284f24fd50 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.json @@ -29,8 +29,8 @@ "col_break_warehouse", "supplier_warehouse", "items_section", - "get_scrap_items", "items", + "get_secondary_items", "section_break0", "total_qty", "column_break_27", @@ -631,13 +631,6 @@ "label": "Edit Posting Date and Time", "print_hide": 1 }, - { - "depends_on": "eval: (!doc.__islocal && doc.docstatus == 0)", - "fieldname": "get_scrap_items", - "fieldtype": "Button", - "label": "Get Scrap Items", - "options": "get_scrap_items" - }, { "fieldname": "supplier_delivery_note", "fieldtype": "Data", @@ -674,12 +667,19 @@ "fieldtype": "Tab Break", "label": "Connections", "show_dashboard": 1 + }, + { + "depends_on": "eval: (!doc.__islocal && doc.docstatus == 0)", + "fieldname": "get_secondary_items", + "fieldtype": "Button", + "label": "Get Secondary Items", + "options": "get_secondary_items" } ], "in_create": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-08 21:43:27.065640", + "modified": "2026-02-27 17:59:44.107193", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt", diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 2456e2ef90f..664adf254f8 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -144,12 +144,12 @@ class SubcontractingReceipt(SubcontractingController): super().validate() if self.is_new() and self.get("_action") == "save" and not frappe.in_test: - self.get_scrap_items() + self.get_secondary_items() self.set_missing_values() if self.get("_action") == "submit": - self.validate_scrap_items() + self.validate_secondary_items() self.validate_accepted_warehouse() self.validate_rejected_warehouse() @@ -343,39 +343,66 @@ class SubcontractingReceipt(SubcontractingController): self.update_rate_for_supplied_items() @frappe.whitelist() - def get_scrap_items(self, recalculate_rate=False): - self.remove_scrap_items() + def get_secondary_items(self, recalculate_rate: bool | None = False): + self.remove_secondary_items() for item in list(self.items): if item.bom: bom = frappe.get_doc("BOM", item.bom) - for scrap_item in bom.scrap_items: - qty = flt(item.qty) * (flt(scrap_item.stock_qty) / flt(bom.quantity)) - rate = ( - get_valuation_rate( - scrap_item.item_code, - self.set_warehouse, - self.doctype, - self.name, - currency=erpnext.get_company_currency(self.company), - company=self.company, - ) - or scrap_item.rate + for secondary_item in bom.secondary_items: + per_unit = secondary_item.stock_qty / bom.quantity + received_qty = flt(item.received_qty * per_unit, item.precision("received_qty")) + qty = flt( + item.received_qty * (per_unit - (secondary_item.process_loss_qty / bom.quantity)), + item.precision("qty"), ) + if not secondary_item.is_legacy: + lcv_cost_per_qty = ( + flt(item.landed_cost_voucher_amount) / flt(item.qty) if flt(item.qty) else 0.0 + ) + fg_item_cost = ( + flt(item.rm_cost_per_qty) + + flt(item.secondary_items_cost_per_qty) + + flt(item.additional_cost_per_qty) + + flt(lcv_cost_per_qty) + + flt(item.service_cost_per_qty) + ) * flt(item.received_qty) + rate = ( + (item.amount if self.is_new() else fg_item_cost) + * (secondary_item.cost_allocation_per / 100) + ) / qty + else: + rate = ( + get_valuation_rate( + secondary_item.item_code, + self.set_warehouse, + self.doctype, + self.name, + currency=erpnext.get_company_currency(self.company), + company=self.company, + ) + or secondary_item.rate + ) + self.append( "items", { - "is_scrap_item": 1, + "type": secondary_item.type, + "is_legacy_scrap_item": secondary_item.is_legacy, "reference_name": item.name, - "item_code": scrap_item.item_code, - "item_name": scrap_item.item_name, - "qty": qty, - "stock_uom": scrap_item.stock_uom, + "item_code": secondary_item.item_code, + "item_name": secondary_item.item_name, + "qty": received_qty + if not secondary_item.is_legacy + else flt(item.qty) * (flt(secondary_item.stock_qty) / flt(bom.quantity)), + "received_qty": received_qty, + "process_loss_qty": received_qty - qty, + "stock_uom": secondary_item.stock_uom, "rate": rate, "rm_cost_per_qty": 0, "service_cost_per_qty": 0, "additional_cost_per_qty": 0, - "scrap_cost_per_qty": 0, + "secondary_items_cost_per_qty": 0, "amount": qty * rate, "warehouse": self.set_warehouse, "rejected_warehouse": self.rejected_warehouse, @@ -386,15 +413,12 @@ class SubcontractingReceipt(SubcontractingController): self.calculate_additional_costs() self.calculate_items_qty_and_amount() - def remove_scrap_items(self, recalculate_rate=False): + def remove_secondary_items(self): for item in list(self.items): - if item.is_scrap_item: + if item.type or item.is_legacy_scrap_item: self.remove(item) else: - item.scrap_cost_per_qty = 0 - - if recalculate_rate: - self.calculate_items_qty_and_amount() + item.secondary_items_cost_per_qty = 0 @frappe.whitelist() def set_missing_values(self): @@ -449,30 +473,35 @@ class SubcontractingReceipt(SubcontractingController): else: rm_cost_map[item.reference_name] = item.amount - scrap_cost_map = {} + secondary_items_cost_map = {} for item in self.get("items") or []: - if item.is_scrap_item: - item.amount = flt(item.qty) * flt(item.rate) + if item.type or item.is_legacy_scrap_item: + qty = ( + flt(item.qty) + if item.is_legacy_scrap_item + else (flt(item.received_qty) - flt(item.process_loss_qty)) + ) + item.amount = qty * flt(item.rate) - if item.reference_name in scrap_cost_map: - scrap_cost_map[item.reference_name] += item.amount + if item.reference_name in secondary_items_cost_map: + secondary_items_cost_map[item.reference_name] += item.amount else: - scrap_cost_map[item.reference_name] = item.amount + secondary_items_cost_map[item.reference_name] = item.amount total_qty = total_amount = 0 for item in self.get("items") or []: - if not item.is_scrap_item: + if not item.type and not item.is_legacy_scrap_item: if item.qty: if item.name in rm_cost_map: item.rm_supp_cost = rm_cost_map[item.name] - item.rm_cost_per_qty = item.rm_supp_cost / item.qty + item.rm_cost_per_qty = item.rm_supp_cost / (item.received_qty or item.qty) rm_cost_map.pop(item.name) - if item.name in scrap_cost_map: - item.scrap_cost_per_qty = scrap_cost_map[item.name] / item.qty - scrap_cost_map.pop(item.name) + if item.name in secondary_items_cost_map: + item.secondary_items_cost_per_qty = secondary_items_cost_map[item.name] / item.qty + secondary_items_cost_map.pop(item.name) else: - item.scrap_cost_per_qty = 0 + item.secondary_items_cost_per_qty = 0 lcv_cost_per_qty = 0.0 if item.landed_cost_voucher_amount: @@ -483,36 +512,44 @@ class SubcontractingReceipt(SubcontractingController): + flt(item.service_cost_per_qty) + flt(item.additional_cost_per_qty) + flt(lcv_cost_per_qty) - - flt(item.scrap_cost_per_qty) ) - item.received_qty = flt(item.qty) + flt(item.rejected_qty) - item.amount = flt(item.qty) * flt(item.rate) + if item.bom: + item.received_qty = flt(item.qty) + flt(item.rejected_qty) + flt(item.process_loss_qty) + item.amount = ( + flt(item.received_qty) + * flt(item.rate) + * (frappe.get_value("BOM", item.bom, "cost_allocation_per") / 100) + ) + item.rate = item.amount / (item.qty or item.rejected_qty) + else: + item.qty = flt(item.received_qty) - flt(item.process_loss_qty) + item.amount = flt(item.qty) * flt(item.rate) - total_qty += flt(item.qty) + total_qty += flt(item.qty) + flt(item.rejected_qty) total_amount += item.amount else: self.total_qty = total_qty self.total = total_amount - def validate_scrap_items(self): + def validate_secondary_items(self): for item in self.items: - if item.is_scrap_item: + if item.type or item.is_legacy_scrap_item: if not item.qty: frappe.throw( - _("Row #{0}: Scrap Item Qty cannot be zero").format(item.idx), + _("Row #{0}: Secondary Item Qty cannot be zero").format(item.idx), ) if item.rejected_qty: frappe.throw( - _("Row #{0}: Rejected Qty cannot be set for Scrap Item {1}.").format( + _("Row #{0}: Rejected Qty cannot be set for Secondary Item {1}.").format( item.idx, frappe.bold(item.item_code) ), ) if not item.reference_name: frappe.throw( - _("Row #{0}: Finished Good reference is mandatory for Scrap Item {1}.").format( + _("Row #{0}: Finished Good reference is mandatory for Secondary Item {1}.").format( item.idx, frappe.bold(item.item_code) ), ) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index 53466f7405d..b4b0c930082 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -597,6 +597,7 @@ class TestSubcontractingReceipt(ERPNextTestSuite): scr.items[0].qty = 6 # Accepted Qty scr.items[0].rejected_qty = 4 + scr.set_missing_values() scr.save() # consumed_qty should be (accepted_qty * qty_consumed_per_unit) = (6 * 1) = 6 @@ -1154,7 +1155,7 @@ class TestSubcontractingReceipt(ERPNextTestSuite): # ValidationError should not be raised as `Inspection Required before Purchase` is disabled scr2.submit() - def test_scrap_items_for_subcontracting_receipt(self): + def test_secondary_items_for_subcontracting_receipt(self): set_backflush_based_on("BOM") fg_item = "Subcontracted Item SA1" @@ -1166,9 +1167,9 @@ class TestSubcontractingReceipt(ERPNextTestSuite): ] # Create Scrap Items - scrap_item_1 = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name - scrap_item_2 = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name - scrap_items = [scrap_item_1, scrap_item_2] + secondary_item_1 = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name + secondary_item_2 = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name + secondary_items = [secondary_item_1, secondary_item_2] service_items = [ { @@ -1187,13 +1188,14 @@ class TestSubcontractingReceipt(ERPNextTestSuite): ) for idx, item in enumerate(bom.items): item.qty = 1 * (idx + 1) - for idx, item in enumerate(scrap_items): + for idx, item in enumerate(secondary_items): bom.append( - "scrap_items", + "secondary_items", { "item_code": item, "stock_qty": 1 * (idx + 1), "rate": 10 * (idx + 1), + "is_legacy": 1, }, ) bom.save() @@ -1216,12 +1218,13 @@ class TestSubcontractingReceipt(ERPNextTestSuite): # Create Subcontracting Receipt scr = make_subcontracting_receipt(sco.name) scr.save() - scr.get_scrap_items() + scr.get_secondary_items() - # Test - 1: Scrap Items should be fetched from BOM in items table with `is_scrap_item` = 1 - scr_scrap_items = set([item.item_code for item in scr.items if item.is_scrap_item]) + scr_secondary_items = set( + [item.item_code for item in scr.items if item.type or item.is_legacy_scrap_item] + ) self.assertEqual(len(scr.items), 3) # 1 FG Item + 2 Scrap Items - self.assertEqual(scr_scrap_items, set(scrap_items)) + self.assertEqual(scr_secondary_items, set(secondary_items)) scr.submit() diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json index 9c1f8e60946..b6d07f66b98 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json @@ -8,9 +8,10 @@ "engine": "InnoDB", "field_order": [ "item_code", + "is_legacy_scrap_item", + "type", "column_break_2", "item_name", - "is_scrap_item", "section_break_4", "description", "brand", @@ -22,6 +23,7 @@ "qty", "rejected_qty", "returned_qty", + "process_loss_qty", "col_break2", "stock_uom", "conversion_factor", @@ -33,7 +35,7 @@ "rm_cost_per_qty", "service_cost_per_qty", "additional_cost_per_qty", - "scrap_cost_per_qty", + "secondary_items_cost_per_qty", "rm_supp_cost", "warehouse_and_reference", "warehouse", @@ -144,7 +146,7 @@ "default": "0", "fieldname": "received_qty", "fieldtype": "Float", - "label": "Received Quantity", + "label": "Qty (As per BOM)", "no_copy": 1, "print_hide": 1, "print_width": "100px", @@ -157,22 +159,23 @@ "fieldname": "qty", "fieldtype": "Float", "in_list_view": 1, - "label": "Accepted Quantity", + "label": "Accepted Qty", "no_copy": 1, "print_width": "100px", + "read_only_depends_on": "eval:doc.type || doc.is_legacy_scrap_item", "width": "100px" }, { "columns": 1, - "depends_on": "eval: !parent.is_return", + "depends_on": "eval:!parent.is_return && !doc.type && !doc.is_legacy_scrap_item", "fieldname": "rejected_qty", "fieldtype": "Float", "in_list_view": 1, - "label": "Rejected Quantity", + "label": "Rejected Qty", "no_copy": 1, "print_hide": 1, "print_width": "100px", - "read_only_depends_on": "eval: doc.is_scrap_item", + "read_only_depends_on": "eval:doc.type || doc.is_legacy_scrap_item", "width": "100px" }, { @@ -181,6 +184,7 @@ "print_hide": 1 }, { + "fetch_from": "item_code.stock_uom", "fieldname": "stock_uom", "fieldtype": "Link", "label": "Stock UOM", @@ -230,7 +234,7 @@ }, { "default": "0", - "depends_on": "eval: !doc.is_scrap_item", + "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", "fieldname": "rm_cost_per_qty", "fieldtype": "Currency", "label": "Raw Material Cost Per Qty", @@ -240,7 +244,7 @@ }, { "default": "0", - "depends_on": "eval: !doc.is_scrap_item", + "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", "fieldname": "service_cost_per_qty", "fieldtype": "Currency", "label": "Service Cost Per Qty", @@ -250,7 +254,7 @@ }, { "default": "0", - "depends_on": "eval: !doc.is_scrap_item", + "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", "fieldname": "additional_cost_per_qty", "fieldtype": "Currency", "label": "Additional Cost Per Qty", @@ -274,7 +278,7 @@ "width": "100px" }, { - "depends_on": "eval: !parent.is_return", + "depends_on": "eval: !parent.is_return && !doc.type && !doc.is_legacy_scrap_item", "fieldname": "rejected_warehouse", "fieldtype": "Link", "ignore_user_permissions": 1, @@ -283,11 +287,10 @@ "options": "Warehouse", "print_hide": 1, "print_width": "100px", - "read_only_depends_on": "eval: doc.is_scrap_item", "width": "100px" }, { - "depends_on": "eval:!doc.__islocal", + "depends_on": "eval:!doc.__islocal && !doc.type && !doc.is_legacy_scrap_item", "fieldname": "quality_inspection", "fieldtype": "Link", "label": "Quality Inspection", @@ -369,7 +372,7 @@ "no_copy": 1, "options": "BOM", "print_hide": 1, - "read_only_depends_on": "eval: doc.is_scrap_item" + "read_only_depends_on": "eval:doc.type || doc.is_legacy_scrap_item" }, { "fetch_from": "item_code.brand", @@ -496,7 +499,7 @@ "print_hide": 1 }, { - "depends_on": "eval:doc.use_serial_batch_fields === 0 || doc.docstatus === 1", + "depends_on": "eval:(doc.use_serial_batch_fields === 0 || doc.docstatus === 1) && !doc.type && !doc.is_legacy_scrap_item", "fieldname": "rejected_serial_and_batch_bundle", "fieldtype": "Link", "label": "Rejected Serial and Batch Bundle", @@ -504,26 +507,6 @@ "options": "Serial and Batch Bundle", "print_hide": 1 }, - { - "default": "0", - "depends_on": "eval: !doc.bom", - "fieldname": "is_scrap_item", - "fieldtype": "Check", - "label": "Is Scrap Item", - "no_copy": 1, - "print_hide": 1, - "read_only_depends_on": "eval: doc.bom" - }, - { - "default": "0", - "depends_on": "eval: !doc.is_scrap_item", - "fieldname": "scrap_cost_per_qty", - "fieldtype": "Float", - "label": "Scrap Cost Per Qty", - "no_copy": 1, - "non_negative": 1, - "read_only": 1 - }, { "fieldname": "reference_name", "fieldtype": "Data", @@ -553,6 +536,7 @@ }, { "default": "0", + "depends_on": "eval:doc.bom", "fieldname": "include_exploded_items", "fieldtype": "Check", "label": "Include Exploded Items", @@ -580,7 +564,7 @@ "label": "Add Serial / Batch Bundle" }, { - "depends_on": "eval:doc.use_serial_batch_fields === 0", + "depends_on": "eval:doc.use_serial_batch_fields === 0 && !doc.type && !doc.is_legacy_scrap_item", "fieldname": "add_serial_batch_for_rejected_qty", "fieldtype": "Button", "label": "Add Serial / Batch No (Rejected Qty)" @@ -594,6 +578,7 @@ "search_index": 1 }, { + "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", "fieldname": "landed_cost_voucher_amount", "fieldtype": "Currency", "label": "Landed Cost Voucher Amount", @@ -609,13 +594,48 @@ "fieldtype": "Link", "label": "Service Expense Account", "options": "Account" + }, + { + "fieldname": "type", + "fieldtype": "Select", + "label": "Type", + "no_copy": 1, + "options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good", + "print_hide": 1, + "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", + "fieldname": "secondary_items_cost_per_qty", + "fieldtype": "Currency", + "label": "Secondary Items Cost Per Qty", + "no_copy": 1, + "non_negative": 1, + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "default": "0", + "depends_on": "is_legacy_scrap_item", + "fieldname": "is_legacy_scrap_item", + "fieldtype": "Check", + "label": "Is Legacy Scrap Item", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "process_loss_qty", + "fieldtype": "Float", + "label": "Process Loss Qty", + "non_negative": 1 } ], "grid_page_length": 50, "idx": 1, "istable": 1, "links": [], - "modified": "2025-09-26 12:00:38.877638", + "modified": "2026-03-09 15:11:16.977539", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py index e916a90462f..c6233b841a2 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py @@ -25,7 +25,7 @@ class SubcontractingReceiptItem(Document): expense_account: DF.Link | None image: DF.Attach | None include_exploded_items: DF.Check - is_scrap_item: DF.Check + is_legacy_scrap_item: DF.Check item_code: DF.Link item_name: DF.Data | None job_card: DF.Link | None @@ -36,6 +36,7 @@ class SubcontractingReceiptItem(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data + process_loss_qty: DF.Float project: DF.Link | None purchase_order: DF.Link | None purchase_order_item: DF.Data | None @@ -52,7 +53,7 @@ class SubcontractingReceiptItem(Document): rm_cost_per_qty: DF.Currency rm_supp_cost: DF.Currency schedule_date: DF.Date | None - scrap_cost_per_qty: DF.Float + secondary_items_cost_per_qty: DF.Currency serial_and_batch_bundle: DF.Link | None serial_no: DF.SmallText | None service_cost_per_qty: DF.Currency @@ -61,6 +62,7 @@ class SubcontractingReceiptItem(Document): subcontracting_order: DF.Link | None subcontracting_order_item: DF.Data | None subcontracting_receipt_item: DF.Data | None + type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"] use_serial_batch_fields: DF.Check warehouse: DF.Link | None # end: auto-generated types