diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 7e3f00774fe..45e2774734d 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1441,7 +1441,7 @@ class StockController(AccountsController): elif self.doctype == "Stock Entry" and row.t_warehouse: qi_required = True # inward stock needs inspection - if row.get("type") or row.get("is_legacy_scrap_item"): + if row.get("secondary_item_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 e922a0ea9fc..4859cd3040f 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("type") and not item.get("is_legacy_scrap_item"): + if not item.get("secondary_item_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) @@ -1287,10 +1287,10 @@ class SubcontractingController(StockController): total_amt = sum( flt(item.amount) for item in self.get("items") - if not item.get("type") and not item.get("is_legacy_scrap_item") + if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item") ) for item in self.items: - if not item.get("type") and not item.get("is_legacy_scrap_item"): + if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"): item.additional_cost_per_qty = ( (item.amount * self.total_additional_costs) / total_amt ) / item.qty @@ -1298,15 +1298,15 @@ class SubcontractingController(StockController): 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") + if not item.get("secondary_item_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("type") and not item.get("is_legacy_scrap_item"): + if not item.get("secondary_item_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("type") and not item.get("is_legacy_scrap_item"): + if not item.get("secondary_item_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 490f7204d2a..4317ab560ae 100644 --- a/erpnext/controllers/subcontracting_inward_controller.py +++ b/erpnext/controllers/subcontracting_inward_controller.py @@ -241,7 +241,7 @@ class SubcontractingInwardController: item for item in self.get("items") if not item.is_finished_item - and not item.type + and not item.secondary_item_type and not item.is_legacy_scrap_item and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item") ] @@ -372,7 +372,7 @@ class SubcontractingInwardController: if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]: for item in self.items: if ( - item.is_finished_item or item.type or item.is_legacy_scrap_item + item.is_finished_item or item.secondary_item_type or item.is_legacy_scrap_item ) and item.valuation_rate == 0: item.allow_zero_valuation_rate = 1 @@ -472,7 +472,7 @@ class SubcontractingInwardController: self.validate_delivery_on_save() else: for item in self.items: - if not item.type and not item.is_legacy_scrap_item: + if not item.secondary_item_type and not item.is_legacy_scrap_item: delivered_qty, returned_qty = frappe.get_value( "Subcontracting Inward Order Item", item.scio_detail, @@ -543,7 +543,7 @@ class SubcontractingInwardController: bold( frappe.get_cached_value( "Subcontracting Inward Order Item" - if not item.type and not item.is_legacy_scrap_item + if not item.secondary_item_type and not item.is_legacy_scrap_item else "Subcontracting Inward Order Secondary Item", item.scio_detail, "stock_uom", @@ -595,7 +595,7 @@ class SubcontractingInwardController: ) for item in [item for item in self.items if not item.is_finished_item]: - if item.type or item.is_legacy_scrap_item: + if item.secondary_item_type or item.is_legacy_scrap_item: scio_secondary_item = frappe.get_value( "Subcontracting Inward Order Secondary Item", { @@ -655,7 +655,7 @@ class SubcontractingInwardController: for item in self.items: doctype = ( "Subcontracting Inward Order Item" - if not item.type and not item.is_legacy_scrap_item + if not item.secondary_item_type and not item.is_legacy_scrap_item else "Subcontracting Inward Order Secondary Item" ) qty_map[doctype][item.scio_detail] += ( @@ -780,7 +780,7 @@ class SubcontractingInwardController: items = [ item for item in self.items - if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item + if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item ] item_code_wh = frappe._dict( { @@ -882,7 +882,9 @@ class SubcontractingInwardController: def update_inward_order_secondary_items(self): if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture": - secondary_items_list = [item for item in self.items if item.type or item.is_legacy_scrap_item] + secondary_items_list = [ + item for item in self.items if item.secondary_item_type or item.is_legacy_scrap_item + ] secondary_items = defaultdict(float) for item in secondary_items_list: @@ -956,7 +958,7 @@ class SubcontractingInwardController: stock_uom=secondary_item.stock_uom, warehouse=secondary_item.t_warehouse, produced_qty=secondary_item.transfer_qty, - type=secondary_item.type, + secondary_item_type=secondary_item.secondary_item_type, delivered_qty=0, reference_name=frappe.get_value( "Work Order", self.work_order, "subcontracting_inward_order_item" diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 1619a0053a9..211b6a33f73 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -337,14 +337,14 @@ class BOM(WebsiteGenerator): 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) + item.idx, item.secondary_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) + item.idx, item.secondary_item_type, get_link_to_form("Item", item.item_code) ) ) @@ -1276,7 +1276,9 @@ class BOM(WebsiteGenerator): 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")) + return any( + d.get("secondary_item_type") == "Scrap" or d.get("is_legacy") for d in self.get("secondary_items") + ) def get_bom_item_rate(args, bom_doc): @@ -1444,7 +1446,7 @@ def get_bom_items_as_dict( query = query.format( table="BOM Secondary Item", where_conditions=")", - select_columns=", item.description, bom_item.cost_allocation_per, bom_item.process_loss_per, bom_item.type, bom_item.name, bom_item.is_legacy", + select_columns=", item.description, bom_item.cost_allocation_per, bom_item.process_loss_per, bom_item.secondary_item_type, bom_item.name, bom_item.is_legacy", is_stock_item=is_stock_item, qty_field="stock_qty", group_by_cond=group_by_cond, diff --git a/erpnext/manufacturing/doctype/bom/test_records.json b/erpnext/manufacturing/doctype/bom/test_records.json index 7c5c41fec19..2386fd0f38b 100644 --- a/erpnext/manufacturing/doctype/bom/test_records.json +++ b/erpnext/manufacturing/doctype/bom/test_records.json @@ -45,7 +45,7 @@ "stock_qty": 1.0, "rate": 2000.0, "stock_uom": "_Test UOM", - "type": "Scrap", + "secondary_item_type": "Scrap", "is_legacy": 1 } ], diff --git a/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.json b/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.json index 39fa55123f4..18615cb193b 100644 --- a/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.json +++ b/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.json @@ -6,7 +6,7 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "type", + "secondary_item_type", "rate", "column_break_gres", "is_legacy", @@ -35,7 +35,7 @@ "fields": [ { "depends_on": "eval:!doc.is_legacy", - "fieldname": "type", + "fieldname": "secondary_item_type", "fieldtype": "Select", "in_list_view": 1, "label": "Type", @@ -218,7 +218,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-03-11 12:12:29.208031", + "modified": "2026-06-01 10:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Secondary Item", diff --git a/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.py b/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.py index 87748fe2269..577eb0bd6e2 100644 --- a/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.py +++ b/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.py @@ -32,7 +32,7 @@ class BOMSecondaryItem(Document): rate: DF.Currency stock_qty: DF.Float stock_uom: DF.Link | None - type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"] + secondary_item_type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"] uom: DF.Link # end: auto-generated types diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 137788346c2..775e6c9f48b 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -294,7 +294,7 @@ class JobCard(Document): "stock_qty": values.qty, "item_name": values.item_name, "stock_uom": values.stock_uom, - "type": values.type, + "secondary_item_type": values.secondary_item_type, "bom_secondary_item": values.name, } @@ -1531,7 +1531,7 @@ class JobCard(Document): ste.stock_entry.pro_doc = frappe.get_doc("Work Order", self.work_order) ste.stock_entry.set_secondary_items_from_job_card() for row in ste.stock_entry.items: - if (row.type or row.is_legacy_scrap_item) and not row.t_warehouse: + if (row.secondary_item_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 7174347d5bb..38d1a444588 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -912,7 +912,7 @@ class TestJobCard(ERPNextTestSuite): "qty": 1, "process_loss_per": 10, "cost_allocation_per": 5, - "type": "Scrap", + "secondary_item_type": "Scrap", }, ) if submit: @@ -995,7 +995,8 @@ class TestJobCard(ERPNextTestSuite): }, ) job_card.append( - "secondary_items", {"item_code": scrap_extra.name, "stock_qty": 5, "type": "Co-Product"} + "secondary_items", + {"item_code": scrap_extra.name, "stock_qty": 5, "secondary_item_type": "Co-Product"}, ) job_card.submit() @@ -1014,7 +1015,7 @@ class TestJobCard(ERPNextTestSuite): 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].secondary_item_type, "Co-Product") self.assertEqual(manufacturing_entry.items[3].qty, 5) self.assertEqual(manufacturing_entry.items[3].basic_rate, 0) @@ -1059,7 +1060,9 @@ class TestJobCard(ERPNextTestSuite): ) 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( + "secondary_items", {"item_code": "_Test Item", "stock_qty": 2, "secondary_item_type": "Scrap"} + ) job_card.append( "time_logs", { diff --git a/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.json b/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.json index d9ac0e08ced..d367d7e308c 100644 --- a/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.json +++ b/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.json @@ -5,7 +5,7 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "type", + "secondary_item_type", "description", "column_break_3", "item_code", @@ -69,7 +69,7 @@ "read_only": 1 }, { - "fieldname": "type", + "fieldname": "secondary_item_type", "fieldtype": "Select", "in_list_view": 1, "label": "Type", @@ -87,7 +87,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-03-06 13:51:00.492621", + "modified": "2026-06-01 10:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Secondary Item", diff --git a/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.py b/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.py index 3a71ab9d755..db61f3cad48 100644 --- a/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.py +++ b/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.py @@ -22,7 +22,7 @@ class JobCardSecondaryItem(Document): parenttype: DF.Data stock_qty: DF.Float stock_uom: DF.Link | None - type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"] + secondary_item_type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"] # end: auto-generated types pass diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 9ab329b8f19..f1c4b9615bc 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -2906,7 +2906,7 @@ def make_bom(**args): bom.append( "secondary_items", { - "type": "Scrap", + "secondary_item_type": "Scrap", "item_code": item, "item_name": item, "uom": item_doc.stock_uom, diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 0c595df283d..de62971cef4 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1092,7 +1092,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.type or row.is_legacy_scrap_item: + if row.secondary_item_type or row.is_legacy_scrap_item: self.assertEqual(row.qty, 1) # Partial Job Card 1 with qty 10 @@ -1104,7 +1104,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.type or row.is_legacy_scrap_item: + if row.secondary_item_type or row.is_legacy_scrap_item: self.assertEqual(row.qty, 2) # Partial Job Card 2 with qty 10 @@ -2191,7 +2191,7 @@ class TestWorkOrder(ERPNextTestSuite): self.assertTrue(se_doc.additional_costs) secondary_items = [] for item in se_doc.items: - if item.type or item.is_legacy_scrap_item: + if item.secondary_item_type or item.is_legacy_scrap_item: secondary_items.append(item.item_code) self.assertEqual( @@ -2656,7 +2656,7 @@ class TestWorkOrder(ERPNextTestSuite): # Secondary/Scrap item: should be taken from scrap warehouse in disassembly scrap_row = next((i for i in stock_entry.items if i.item_code == scrap_item), None) self.assertIsNotNone(scrap_row) - self.assertEqual(scrap_row.type, "Scrap") + self.assertEqual(scrap_row.secondary_item_type, "Scrap") self.assertTrue(scrap_row.s_warehouse) self.assertFalse(scrap_row.t_warehouse) self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 4e3ba999eb9..2da1ad5d4af 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -483,3 +483,4 @@ erpnext.patches.v16_0.fix_titles erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates erpnext.patches.v16_0.clear_procedures_from_receivable_report erpnext.patches.v16_0.migrate_address_contact_custom_fields +erpnext.patches.v16_0.rename_secondary_item_type_field diff --git a/erpnext/patches/v16_0/co_by_product_patch.py b/erpnext/patches/v16_0/co_by_product_patch.py index 63f43e85b9e..5baca92b31a 100644 --- a/erpnext/patches/v16_0/co_by_product_patch.py +++ b/erpnext/patches/v16_0/co_by_product_patch.py @@ -41,7 +41,7 @@ def insert_into_bom(): "conversion_factor": 1, "qty": item.stock_qty, "is_legacy": 1, - "type": "Scrap", + "secondary_item_type": "Scrap", } ) secondary_item.insert() @@ -49,7 +49,14 @@ def insert_into_bom(): 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"]) + bulk_insert( + "Job Card", + "Job Card Scrap Item", + "Job Card Secondary Item", + fields, + ["secondary_item_type"], + ["Scrap"], + ) def insert_into_subcontracting_inward(): @@ -67,7 +74,7 @@ def insert_into_subcontracting_inward(): "Subcontracting Inward Order Scrap Item", "Subcontracting Inward Order Secondary Item", fields, - ["type"], + ["secondary_item_type"], ["Scrap"], ) diff --git a/erpnext/patches/v16_0/rename_secondary_item_type_field.py b/erpnext/patches/v16_0/rename_secondary_item_type_field.py new file mode 100644 index 00000000000..41b264b7ccb --- /dev/null +++ b/erpnext/patches/v16_0/rename_secondary_item_type_field.py @@ -0,0 +1,18 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + + +def execute(): + doctypes = [ + "BOM Secondary Item", + "Job Card Secondary Item", + "Stock Entry Detail", + "Subcontracting Inward Order Secondary Item", + "Subcontracting Receipt Item", + ] + + for doctype in doctypes: + if not frappe.db.has_column(doctype, "type"): + continue + + rename_field(doctype, "type", "secondary_item_type") diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 995e07f74ae..d7511d6578a 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1217,9 +1217,19 @@ class StockEntry(StockController, SubcontractingInwardController): ) d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty +<<<<<<< HEAD if not d.basic_rate and not d.allow_zero_valuation_rate: if self.is_new(): raise_error_if_no_rate = False +======= + if self.bom_no: + d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100 + elif d.secondary_item_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 +>>>>>>> dbcfac839c (chore: rename type field to secondary_item_type (#55469)) d.basic_rate = get_valuation_rate( d.item_code, @@ -1372,6 +1382,75 @@ class StockEntry(StockController, SubcontractingInwardController): return flt((outgoing_items_cost - scrap_items_cost) / finished_item_qty) +<<<<<<< HEAD +======= + def _get_rm_cost_for_manufacture(self, settings, finished_item_qty, outgoing_items_cost): + if settings.get_rm_cost_from_consumption_entry and self.work_order: + if frappe.db.exists( + "Stock Entry", + { + "docstatus": 1, + "work_order": self.work_order, + "purpose": "Material Consumption for Manufacture", + }, + ): + self._validate_no_raw_materials_in_manufacture_entry(settings) + self._validate_single_manufacture_entry() + return self._fetch_consumption_entry_cost() + elif not outgoing_items_cost: + bom_items = self.get_bom_raw_materials(finished_item_qty) + outgoing_items_cost = sum([flt(row.qty) * flt(row.rate) for row in bom_items.values()]) + + return outgoing_items_cost + + def _validate_no_raw_materials_in_manufacture_entry(self, settings): + for item in self.items: + if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item: + label = frappe.get_meta(settings.doctype).get_label("get_rm_cost_from_consumption_entry") + frappe.throw( + _( + "Row {0}: As {1} is enabled, raw materials cannot be added to {2} entry. Use {3} entry to consume raw materials." + ).format( + item.idx, + frappe.bold(label), + frappe.bold(_("Manufacture")), + frappe.bold(_("Material Consumption for Manufacture")), + ) + ) + + def _validate_single_manufacture_entry(self): + if frappe.db.exists( + "Stock Entry", + { + "docstatus": 1, + "work_order": self.work_order, + "purpose": "Manufacture", + "name": ("!=", self.name), + }, + ): + frappe.throw( + _("Only one {0} entry can be created against the Work Order {1}").format( + frappe.bold(_("Manufacture")), frappe.bold(self.work_order) + ) + ) + + def _fetch_consumption_entry_cost(self): + SE = frappe.qb.DocType("Stock Entry") + SE_ITEM = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(SE) + .left_join(SE_ITEM) + .on(SE.name == SE_ITEM.parent) + .select(Sum(SE_ITEM.valuation_rate * SE_ITEM.transfer_qty)) + .where( + (SE.docstatus == 1) + & (SE.work_order == self.work_order) + & (SE.purpose == "Material Consumption for Manufacture") + ) + ).run()[0][0] or 0 + +>>>>>>> dbcfac839c (chore: rename type field to secondary_item_type (#55469)) def distribute_additional_costs(self): # If no incoming items, set additional costs blank if not any(d.item_code for d in self.items if d.t_warehouse): @@ -1742,7 +1821,7 @@ class StockEntry(StockController, SubcontractingInwardController): d.is_finished_item = 1 else: d.is_finished_item = 0 - d.type = "" + d.secondary_item_type = "" def get_finished_item(self): finished_item = None diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py new file mode 100644 index 00000000000..767afab2fb7 --- /dev/null +++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py @@ -0,0 +1,529 @@ +from collections import defaultdict + +import frappe +from frappe import _ +from frappe.query_builder.functions import Sum +from frappe.utils import flt + +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +from erpnext.stock.serial_batch_bundle import SerialBatchCreation +from erpnext.stock.utils import get_combine_datetime + +from .base import BaseStockEntry +from .manufacturing import ( + ceil_qty_if_uom_has_whole_number, + get_bom_items, + get_production_item_details, + get_secondary_items, +) + + +class DisassembleStockEntry(BaseStockEntry): + def validate(self): + self.validate_warehouse() + + def validate_warehouse(self): + for row in self.doc.items: + if not row.s_warehouse and not row.t_warehouse: + frappe.throw(_("Source or Target Warehouse is required for item {0}").format(row.item_code)) + + def validate_fg_completed_qty(self): + if not self.doc.source_stock_entry: + return + + from erpnext.manufacturing.doctype.work_order.work_order import get_disassembly_available_qty + + available_qty = get_disassembly_available_qty(self.doc.source_stock_entry, self.doc.name) + + if flt(self.doc.fg_completed_qty) > available_qty: + frappe.throw( + _( + "Cannot disassemble {0} qty against Stock Entry {1}. Only {2} qty available to disassemble." + ).format( + self.doc.fg_completed_qty, + self.doc.source_stock_entry, + available_qty, + ), + title=_("Excess Disassembly"), + ) + + def add_items(self): + """ + Priority: + 1. From a specific Manufacture Stock Entry (exact reversal) + 2. From Work Order Manufacture Stock Entries (averaged reversal) + 3. From BOM (standalone disassembly) + """ + + # Auto-set source_stock_entry if WO has exactly one manufacture entry + if not self.doc.get("source_stock_entry") and self.doc.work_order: + manufacture_entries = frappe.get_all( + "Stock Entry", + filters={ + "work_order": self.doc.work_order, + "purpose": "Manufacture", + "docstatus": 1, + }, + pluck="name", + ) + if len(manufacture_entries) == 1: + self.doc.source_stock_entry = manufacture_entries[0] + + if self.doc.get("source_stock_entry"): + return self._add_items_for_disassembly_from_stock_entry() + + if self.doc.work_order: + return self._add_items_for_disassembly_from_work_order() + + return self._add_items_for_disassembly_from_bom() + + def _add_items_for_disassembly_from_stock_entry(self): + source_fg_qty = frappe.db.get_value("Stock Entry", self.doc.source_stock_entry, "fg_completed_qty") + if not source_fg_qty: + frappe.throw( + _("Source Stock Entry {0} has no finished goods quantity").format(self.doc.source_stock_entry) + ) + + disassemble_qty = flt(self.doc.fg_completed_qty) + scale_factor = disassemble_qty / flt(source_fg_qty) + + self._append_disassembly_row_from_source( + disassemble_qty=disassemble_qty, + scale_factor=scale_factor, + ) + + def _add_items_for_disassembly_from_work_order(self): + wo_produced_qty = frappe.db.get_value("Work Order", self.doc.work_order, "produced_qty") + + wo_produced_qty = flt(wo_produced_qty) + if wo_produced_qty <= 0: + frappe.throw(_("Work Order {0} has no produced qty").format(self.doc.work_order)) + + disassemble_qty = flt(self.doc.fg_completed_qty) + if disassemble_qty <= 0: + frappe.throw(_("Disassemble Qty cannot be less than or equal to 0.")) + + scale_factor = disassemble_qty / wo_produced_qty + + self._append_disassembly_row_from_source( + disassemble_qty=disassemble_qty, + scale_factor=scale_factor, + ) + + def _append_disassembly_row_from_source(self, disassemble_qty, scale_factor): + for source_row in self.get_items_from_manufacture_stock_entry(): + self._append_disassembly_item(source_row, disassemble_qty, scale_factor) + + def _get_disassembly_warehouses(self, source_row, disassemble_qty, scale_factor): + if source_row.is_finished_item: + return disassemble_qty, self.doc.from_warehouse or source_row.t_warehouse, "" + elif source_row.s_warehouse: + return flt(source_row.qty * scale_factor), "", self.doc.to_warehouse or source_row.s_warehouse + else: + return flt(source_row.qty * scale_factor), source_row.t_warehouse, "" + + def _build_disassembly_item_dict(self, source_row, qty, s_warehouse, t_warehouse): + return { + "item_code": source_row.item_code, + "item_name": source_row.item_name, + "description": source_row.description, + "stock_uom": source_row.stock_uom, + "uom": source_row.uom, + "conversion_factor": source_row.conversion_factor, + "basic_rate": source_row.basic_rate, + "qty": qty, + "s_warehouse": s_warehouse, + "t_warehouse": t_warehouse, + "is_finished_item": source_row.is_finished_item, + "secondary_item_type": source_row.secondary_item_type, + "is_legacy_scrap_item": source_row.is_legacy_scrap_item, + "bom_secondary_item": source_row.bom_secondary_item, + "bom_no": source_row.bom_no, + "use_serial_batch_fields": 1 if (source_row.batch_no or source_row.serial_no) else 0, + } + + def _append_disassembly_item(self, source_row, disassemble_qty, scale_factor): + qty, s_warehouse, t_warehouse = self._get_disassembly_warehouses( + source_row, disassemble_qty, scale_factor + ) + item = self._build_disassembly_item_dict(source_row, qty, s_warehouse, t_warehouse) + if self.doc.source_stock_entry: + item.update({"against_stock_entry": self.doc.source_stock_entry, "ste_detail": source_row.name}) + self.doc.append("items", item) + + def _add_items_for_disassembly_from_bom(self): + if not self.doc.bom_no or not self.doc.fg_completed_qty: + frappe.throw(_("BOM and Finished Good Quantity is mandatory for Disassembly")) + + self.add_raw_materials() + self.add_secondary_items() + self.add_finished_goods() + + def add_raw_materials(self): + # Raw materials will be available after disassembly in target warehouse + items = get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom) + + for row in items: + row["t_warehouse"] = self.doc.to_warehouse + row["from_warehouse"] = "" + row["is_finished_item"] = 0 + row["qty"] = flt(row["qty"]) * flt(self.doc.fg_completed_qty) + row["uom"] = row.get("uom") or row.get("stock_uom") + self.doc.append("items", row) + + def add_secondary_items(self): + # Secondary items will be removed from source warehouse + + secondary_items = get_secondary_items(self.doc.bom_no, self.doc.work_order) + for row in secondary_items: + item_args = {} + fields = [ + "item_code", + "item_name", + "uom", + "stock_uom", + "conversion_factor", + "item_group", + "description", + "secondary_item_type", + ] + for field in fields: + item_args[field] = row.get(field) + + item_args["is_legacy_scrap_item"] = row.get("is_legacy") + item_args["s_warehouse"] = self.doc.from_warehouse + item_args["uom"] = item_args.get("uom") or item_args.get("stock_uom") + item_args["bom_secondary_item"] = row.get("name") + + row.qty = row.qty * self.doc.fg_completed_qty + if row.get("process_loss_per"): + row.qty -= flt(row.qty * row.get("process_loss_per") / 100) + item_args["qty"] = ceil_qty_if_uom_has_whole_number(row.qty, item_args["uom"]) + + self.doc.append("items", item_args) + + def add_finished_goods(self): + item_details = get_production_item_details(self.doc.work_order, self.doc.bom_no) + + item_details.update( + { + "conversion_factor": 1, + "uom": item_details.stock_uom, + "qty": self.doc.fg_completed_qty, + "t_warehouse": None, + "s_warehouse": self.doc.from_warehouse, + "is_finished_item": 1, + } + ) + + item_details["item_code"] = item_details["name"] + del item_details["name"] + + self.doc.append("items", item_details) + + def get_items_from_manufacture_stock_entry(self): + SE = frappe.qb.DocType("Stock Entry") + SED = frappe.qb.DocType("Stock Entry Detail") + query = frappe.qb.from_(SED).join(SE).on(SED.parent == SE.name).where(SE.docstatus == 1) + + common_fields = [ + SED.item_code, + SED.item_name, + SED.description, + SED.stock_uom, + SED.uom, + SED.basic_rate, + SED.conversion_factor, + SED.is_finished_item, + SED.secondary_item_type, + SED.is_legacy_scrap_item, + SED.bom_secondary_item, + SED.batch_no, + SED.serial_no, + SED.use_serial_batch_fields, + SED.s_warehouse, + SED.t_warehouse, + SED.bom_no, + ] + + if self.doc.source_stock_entry: + return ( + query.select(SED.name, SED.qty, SED.transfer_qty, *common_fields) + .where(SE.name == self.doc.source_stock_entry) + .orderby(SED.idx) + .run(as_dict=True) + ) + + return ( + query.select(Sum(SED.qty).as_("qty"), Sum(SED.transfer_qty).as_("transfer_qty"), *common_fields) + .where(SE.purpose == "Manufacture") + .where(SE.work_order == self.doc.work_order) + .groupby(SED.item_code) + .orderby(SED.idx) + .run(as_dict=True) + ) + + def on_submit(self): + self.set_serial_batch_for_disassembly() + self.update_disassembled_order() + + def on_cancel(self): + self.update_disassembled_order() + + def set_serial_batch_for_disassembly(self): + if self.doc.get("source_stock_entry"): + self._set_serial_batch_for_disassembly_from_stock_entry() + else: + self._set_serial_batch_for_disassembly_from_available_materials() + + def _set_serial_batch_for_disassembly_from_stock_entry(self): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_voucher_wise_serial_batch_from_bundle, + ) + + source_fg_qty = flt( + frappe.db.get_value("Stock Entry", self.doc.source_stock_entry, "fg_completed_qty") + ) + scale_factor = flt(self.doc.fg_completed_qty) / source_fg_qty if source_fg_qty else 0 + bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=[self.doc.source_stock_entry]) + source_rows_by_name = {r.name: r for r in self.get_items_from_manufacture_stock_entry()} + for row in self.doc.items: + if not row.ste_detail: + continue + source_row = source_rows_by_name.get(row.ste_detail) + if source_row: + self._apply_bundle_to_disassembly_row(row, source_row, bundle_data, scale_factor) + + def _apply_bundle_to_disassembly_row(self, row, source_row, bundle_data, scale_factor): + source_warehouse = source_row.s_warehouse or source_row.t_warehouse + key = (source_row.item_code, source_warehouse, self.doc.source_stock_entry) + source_bundle = bundle_data.get(key, {}) + batches = self._extract_batches(source_row, source_bundle, row, scale_factor) + serial_nos = self._extract_serial_nos(source_row, source_bundle, row) + self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches) + + def _extract_batches(self, source_row, source_bundle, row, scale_factor): + batches = defaultdict(float) + if source_bundle.get("batch_nos"): + self._allocate_batches(batches, source_bundle["batch_nos"], row.transfer_qty, scale_factor) + elif source_row.batch_no: + batches[source_row.batch_no] = row.transfer_qty + return batches + + def _allocate_batches(self, batches, batch_nos, transfer_qty, scale_factor): + qty_remaining = transfer_qty + for batch_no, batch_qty in batch_nos.items(): + if qty_remaining <= 0: + break + alloc = min(abs(flt(batch_qty)) * scale_factor, qty_remaining) + batches[batch_no] = alloc + qty_remaining -= alloc + + def _extract_serial_nos(self, source_row, source_bundle, row): + if source_bundle.get("serial_nos"): + return get_serial_nos(source_bundle["serial_nos"])[: int(row.transfer_qty)] + elif source_row.serial_no: + return get_serial_nos(source_row.serial_no)[: int(row.transfer_qty)] + return [] + + def _set_serial_batch_for_disassembly_from_available_materials(self): + available_materials = get_available_materials(self.doc.work_order, self.doc) + for row in self.doc.items: + warehouse = row.s_warehouse or row.t_warehouse + materials = available_materials.get((row.item_code, warehouse)) + if materials: + self._apply_available_material_bundle(row, materials) + + def _apply_available_material_bundle(self, row, materials): + batches = self._collect_available_batches(materials.batch_details, row.transfer_qty) + serial_nos = materials.serial_nos[: int(row.transfer_qty)] if materials.serial_nos else [] + self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches) + + def _collect_available_batches(self, batch_details, transfer_qty): + batches, qty = defaultdict(float), transfer_qty + for batch_no, batch_qty in batch_details.items(): + if qty <= 0: + break + batch_qty = abs(batch_qty) + if batch_qty <= qty: + batches[batch_no], qty = batch_qty, qty - batch_qty + else: + batches[batch_no], qty = qty, 0 + return batches + + def _set_serial_batch_bundle_for_disassembly_row(self, row, serial_nos, batches): + if not serial_nos and not batches: + return + + warehouse = row.s_warehouse or row.t_warehouse + bundle_doc = SerialBatchCreation( + { + "item_code": row.item_code, + "warehouse": warehouse, + "posting_datetime": get_combine_datetime(self.doc.posting_date, self.doc.posting_time), + "voucher_type": self.doc.doctype, + "voucher_no": self.doc.name, + "voucher_detail_no": row.name, + "qty": row.transfer_qty, + "type_of_transaction": "Inward" if row.t_warehouse else "Outward", + "company": self.doc.company, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches) + + row.serial_and_batch_bundle = bundle_doc.name + row.use_serial_batch_fields = 0 + + def update_disassembled_order(self): + if not self.doc.work_order: + return + + if self.doc.fg_completed_qty: + pro_doc = frappe.get_doc("Work Order", self.doc.work_order) + pro_doc.run_method( + "update_disassembled_qty", self.doc.fg_completed_qty, self.doc._action == "cancel" + ) + + +def get_available_materials(work_order, stock_entry_doc=None) -> dict: + data = get_stock_entry_data(work_order, stock_entry_doc=stock_entry_doc) + available_materials = {} + for row in data: + key = _get_material_key(row, stock_entry_doc) + if key not in available_materials: + available_materials[key] = frappe._dict( + {"item_details": row, "batch_details": defaultdict(float), "qty": 0, "serial_nos": []} + ) + _update_material_qty(available_materials[key], row, stock_entry_doc) + return available_materials + + +def _get_material_key(row, stock_entry_doc): + if stock_entry_doc and stock_entry_doc.purpose == "Disassemble": + return (row.item_code, row.s_warehouse or row.warehouse) + if row.purpose != "Material Transfer for Manufacture": + return (row.item_code, row.s_warehouse) + return (row.item_code, row.warehouse) + + +def _update_material_qty(item_data, row, stock_entry_doc): + is_inward = row.purpose == "Material Transfer for Manufacture" or ( + stock_entry_doc and stock_entry_doc.purpose == "Disassemble" and row.purpose == "Manufacture" + ) + if is_inward: + _add_inward_material_qty(item_data, row) + else: + _deduct_consumed_material_qty(item_data, row) + + +def _add_inward_material_qty(item_data, row): + item_data.qty += row.qty + if row.batch_no: + item_data.batch_details[row.batch_no] += row.qty + elif row.batch_nos: + for batch_no, qty in row.batch_nos.items(): + item_data.batch_details[batch_no] += qty + _extend_serial_nos_from_row(item_data, row) + + +def _extend_serial_nos_from_row(item_data, row): + sn = row.serial_no or row.serial_nos + if sn: + item_data.serial_nos.extend(get_serial_nos(sn)) + item_data.serial_nos.sort() + + +def _deduct_consumed_material_qty(item_data, row): + item_data.qty -= row.qty + if row.batch_no: + item_data.batch_details[row.batch_no] -= row.qty + elif row.batch_nos: + for batch_no, qty in row.batch_nos.items(): + item_data.batch_details[batch_no] += qty + _remove_serial_nos_from_available(item_data, row) + + +def _remove_serial_nos_from_available(item_data, row): + sn = row.serial_no or row.serial_nos + if not sn: + return + for serial_no in get_serial_nos(sn): + if serial_no in item_data.serial_nos: + item_data.serial_nos.remove(serial_no) + + +def get_stock_entry_data(work_order, stock_entry_doc=None): + data = _run_stock_entry_query(work_order, stock_entry_doc) + if not data: + return [] + _enrich_with_bundle_data(data, stock_entry_doc) + return data + + +def _run_stock_entry_query(work_order, stock_entry_doc): + se = frappe.qb.DocType("Stock Entry") + sed = frappe.qb.DocType("Stock Entry Detail") + query = _build_stock_entry_base_query(se, sed, work_order) + query = _apply_stock_entry_purpose_filter(query, se, sed, stock_entry_doc) + return query.run(as_dict=1) + + +def _build_stock_entry_base_query(se, sed, work_order): + return ( + frappe.qb.from_(se) + .from_(sed) + .select( + sed.item_name, + sed.original_item, + sed.item_code, + sed.qty, + sed.t_warehouse.as_("warehouse"), + sed.s_warehouse.as_("s_warehouse"), + sed.description, + sed.stock_uom, + sed.expense_account, + sed.cost_center, + sed.serial_and_batch_bundle, + sed.batch_no, + sed.serial_no, + se.purpose, + se.name, + ) + .where((se.name == sed.parent) & (se.work_order == work_order) & (se.docstatus == 1)) + .orderby(se.creation, sed.item_code, sed.idx) + ) + + +def _apply_stock_entry_purpose_filter(query, se, sed, stock_entry_doc): + if stock_entry_doc and stock_entry_doc.purpose == "Disassemble": + query = query.where(se.purpose.isin(["Disassemble", "Manufacture"])) + return query.where(se.name != stock_entry_doc.name) + query = query.where( + se.purpose.isin( + ["Manufacture", "Material Consumption for Manufacture", "Material Transfer for Manufacture"] + ) + ) + return query.where(sed.s_warehouse.isnotnull()) + + +def _enrich_with_bundle_data(data, stock_entry_doc): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_voucher_wise_serial_batch_from_bundle, + ) + + voucher_nos = [row.get("name") for row in data if row.get("name")] + if not voucher_nos: + return + bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=voucher_nos) + for row in data: + key = _get_bundle_key(row, stock_entry_doc) + if bundle_data.get(key): + row.update(bundle_data.get(key)) + + +def _get_bundle_key(row, stock_entry_doc): + if stock_entry_doc and stock_entry_doc.purpose == "Disassemble": + return (row.item_code, row.s_warehouse or row.warehouse, row.name) + if row.purpose != "Material Transfer for Manufacture": + return (row.item_code, row.s_warehouse, row.name) + return (row.item_code, row.warehouse, row.name) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/manufacturing.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/manufacturing.py new file mode 100644 index 00000000000..652e8e09ba5 --- /dev/null +++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/manufacturing.py @@ -0,0 +1,1057 @@ +import json +from collections import defaultdict + +import frappe +from frappe import _, bold +from frappe.query_builder.functions import Sum +from frappe.utils import ceil, cint, flt, get_link_to_form + +from erpnext.manufacturing.doctype.bom.bom import add_additional_cost +from erpnext.stock.serial_batch_bundle import ( + SerialBatchCreation, + get_batch_nos, + get_empty_batches_based_work_order, +) + +from .base import BaseStockEntry +from .serial_batch import create_serial_and_batch_bundle + + +class BaseManufactureStockEntry(BaseStockEntry): + def set_default_warehouse(self): + for row in self.doc.items: + if ( + not row.s_warehouse + and self.doc.from_warehouse + and not row.is_finished_item + and not row.is_legacy_scrap_item + and not row.secondary_item_type + ): + row.s_warehouse = self.doc.from_warehouse + row.t_warehouse = None + + elif ( + not row.t_warehouse + and self.doc.to_warehouse + and (row.is_finished_item or row.is_legacy_scrap_item or row.secondary_item_type) + ): + row.t_warehouse = self.doc.to_warehouse + row.s_warehouse = None + + def validate_warehouse(self): + for row in self.doc.items: + if not row.s_warehouse and not row.t_warehouse: + frappe.throw(_("Source or Target Warehouse is required for item {0}").format(row.item_code)) + + def validate_raw_materials_exists(self): + if frappe.db.get_single_value("Manufacturing Settings", "material_consumption"): + return + + raw_materials = [] + for row in self.doc.items: + if row.s_warehouse: + raw_materials.append(row.item_code) + + if not raw_materials: + frappe.throw( + _( + "At least one raw material item must be present in the stock entry for the type {0}" + ).format(bold(self.doc.purpose)), + title=_("Raw Materials Missing"), + ) + + def get_item_dict(self, row): + item_args = {} + fields = [ + "item_code", + "item_name", + "item_group", + "description", + "uom", + "stock_uom", + "conversion_factor", + "allow_alternative_item", + ] + for field in fields: + if row.get(field): + item_args[field] = row.get(field) + + return item_args + + def add_secondary_items(self): + secondary_items = get_secondary_items(self.doc.bom_no, self.doc.work_order) + for row in secondary_items: + item_args = self.get_item_dict(row) + item_args["is_legacy_scrap_item"] = bool(row.get("is_legacy")) + item_args["secondary_item_type"] = row.secondary_item_type + item_args["bom_secondary_item"] = row.name + + if row.secondary_item_type == "Scrap" and self.wo_doc and self.wo_doc.get("scrap_warehouse"): + item_args["t_warehouse"] = self.wo_doc.scrap_warehouse + else: + item_args["t_warehouse"] = self.doc.to_warehouse + + row.qty = row.qty * self.doc.fg_completed_qty + if row.get("process_loss_per"): + row.qty -= flt( + row.qty * row.get("process_loss_per") / 100, self.doc.precision("fg_completed_qty") + ) + + item_args["qty"] = ceil_qty_if_uom_has_whole_number(row.qty, row.uom) + item_args["transfer_qty"] = item_args["qty"] + self.doc.append("items", item_args) + + def set_process_loss_qty(self): + precision = self.doc.precision("process_loss_qty") + if self.doc.work_order: + data = frappe.get_all( + "Work Order Operation", + filters={"parent": self.doc.work_order}, + fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}], + ) + + if data and data[0].process_loss_qty: + process_loss_qty = data[0].process_loss_qty + if flt(self.doc.process_loss_qty, precision) != flt(process_loss_qty, precision): + self.doc.process_loss_qty = flt(process_loss_qty, precision) + + frappe.msgprint( + _("The Process Loss Qty has reset as per job cards Process Loss Qty"), alert=True + ) + + if not self.doc.process_loss_percentage and not self.doc.process_loss_qty: + self.doc.process_loss_percentage = frappe.get_cached_value( + "BOM", self.doc.bom_no, "process_loss_percentage" + ) + + if self.doc.process_loss_percentage and not self.doc.process_loss_qty: + self.doc.process_loss_qty = flt( + (flt(self.doc.fg_completed_qty) * flt(self.doc.process_loss_percentage)) / 100 + ) + elif self.doc.process_loss_qty and not self.doc.process_loss_percentage: + self.doc.process_loss_percentage = flt( + (flt(self.doc.process_loss_qty) / flt(self.doc.fg_completed_qty)) * 100 + ) + + def add_finished_goods(self): + item_details = get_production_item_details(self.doc.work_order, self.doc.bom_no) + fg_item_qty = flt(self.doc.fg_completed_qty) - flt(self.doc.process_loss_qty) + + item_details.update( + { + "conversion_factor": 1, + "uom": item_details.stock_uom, + "qty": ceil_qty_if_uom_has_whole_number(fg_item_qty, item_details.stock_uom), + "t_warehouse": self.doc.to_warehouse, + "s_warehouse": None, + "is_finished_item": 1, + } + ) + + item_details["item_code"] = item_details["name"] + del item_details["name"] + + item_details["transfer_qty"] = item_details["qty"] + + if self.wo_doc and cint( + frappe.db.get_single_value( + "Manufacturing Settings", "make_serial_no_batch_from_work_order", cache=True + ) + ): + if self.wo_doc.has_serial_no: + self.set_serial_nos_for_finished_good(item_details) + elif self.wo_doc.has_batch_no: + self.set_batchwise_finished_goods(item_details) + else: + self.doc.append("items", item_details) + + def set_serial_nos_for_finished_good(self, item_details): + serial_nos = self.get_available_serial_nos_for_fg(item_details.item_code) + if serial_nos: + row = frappe._dict({"serial_nos": serial_nos[0 : cint(item_details.qty)]}) + + _id = create_serial_and_batch_bundle( + self.doc, + row, + frappe._dict( + { + "item_code": item_details.item_code, + "warehouse": item_details.t_warehouse, + } + ), + ) + + item_details.serial_and_batch_bundle = _id + item_details.use_serial_batch_fields = 0 + + self.doc.append("items", item_details) + + def get_available_serial_nos_for_fg(self, item_code) -> list[str]: + return frappe.get_all( + "Serial No", + filters={ + "item_code": item_code, + "warehouse": ("is", "not set"), + "status": "Inactive", + "work_order": self.wo_doc.name, + }, + pluck="name", + order_by="creation asc", + ) + + def set_batchwise_finished_goods(self, item_details): + batches = get_empty_batches_based_work_order(self.doc.work_order, self.doc.pro_doc.production_item) + + if not batches: + self.doc.append("items", item_details) + else: + self.add_batchwise_finished_good(batches, item_details) + + def add_batchwise_finished_good(self, batches, item_details): + qty = flt(self.doc.fg_completed_qty) + row = frappe._dict({"batches_to_be_consume": defaultdict(float)}) + self.update_batches_to_be_consume(batches, row, qty) + if row.batches_to_be_consume: + self._link_fg_bundle_and_append(item_details, row) + + def _link_fg_bundle_and_append(self, item_details, row): + _id = create_serial_and_batch_bundle( + self.doc, + row, + frappe._dict( + {"item_code": self.wo_doc.production_item, "warehouse": item_details.get("t_warehouse")} + ), + ) + item_details["serial_and_batch_bundle"] = _id + self.doc.append("items", item_details) + + def update_batches_to_be_consume(self, batches, row, qty): + qty_to_be_consumed = qty + for batch_no, batch_qty in sorted(batches.items(), key=lambda x: x[0]): + if qty_to_be_consumed <= 0 or batch_qty <= 0: + continue + batch_qty = min(batch_qty, qty_to_be_consumed) + self._consume_batch(row, batch_no, batch_qty) + qty_to_be_consumed -= batch_qty + + def _consume_batch(self, row, batch_no, batch_qty): + row.batches_to_be_consume[batch_no] += batch_qty + if batch_no and row.serial_nos: + serial_nos = self.get_serial_nos_based_on_transferred_batch(batch_no, row.serial_nos) + for sn in serial_nos[: cint(batch_qty)]: + row.serial_nos.remove(sn) + if "batch_details" in row: + row.batch_details[batch_no] -= batch_qty + + +class ManufactureStockEntry(BaseManufactureStockEntry): + def before_validate(self): + self.set_default_warehouse() + self.set_job_card_data() + + def validate(self): + self.validate_warehouse() + self.validate_raw_materials_exists() + self.validate_component_and_quantities() + + def set_job_card_data(self): + if self.doc.job_card and not self.doc.work_order: + data = frappe.db.get_value( + "Job Card", + self.doc.job_card, + ["for_quantity", "work_order", "bom_no", "semi_fg_bom"], + as_dict=1, + ) + self.doc.fg_completed_qty = data.for_quantity + self.doc.work_order = data.work_order + self.doc.from_bom = 1 + self.doc.bom_no = data.semi_fg_bom or data.bom_no + + def validate_component_and_quantities(self): + if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"): + return + + if not self.doc.fg_completed_qty: + return + + rm_items = [item for item in self.doc.items if item.s_warehouse] + if not rm_items: + return + + _check_bom_component_qty(self.doc, get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom)) + + def validate_work_order(self): + if not self.doc.work_order: + frappe.throw(_("Work Order is mandatory")) + + def add_items(self): + self.add_raw_materials() + self.set_process_loss_qty() + self.add_finished_goods() + self.add_secondary_items() + self.add_additional_cost() + self.add_secondary_items_from_job_card() + + def add_raw_materials(self): + if not frappe.db.get_single_value("Manufacturing Settings", "material_consumption"): + if self.backflush_based_on == "BOM" or self.wo_doc.skip_transfer: + self.add_raw_materials_based_on_work_order() + else: + self.add_raw_materials_based_on_transfer() + elif self.backflush_based_on == "BOM": + self.add_unconsumed_raw_materials() + else: + self.add_raw_materials_based_on_transfer() + + def add_unconsumed_raw_materials(self): + wo = self.wo_doc + if not wo: + return + work_order_qty = flt(wo.material_transferred_for_manufacturing) or flt(wo.qty) + wo_qty_to_produce = work_order_qty - flt(wo.produced_qty) + for item in wo.get("required_items"): + self._append_unconsumed_item(item, wo, wo_qty_to_produce) + + def _append_unconsumed_item(self, item, wo, wo_qty_to_produce): + wo_item_qty = flt(item.transferred_qty) or flt(item.required_qty) + wo_qty_unconsumed = wo_item_qty - flt(item.consumed_qty) + bom_qty_per_unit = flt(item.required_qty) / flt(wo.qty) + req_qty_each = min(wo_qty_unconsumed / (wo_qty_to_produce or 1), bom_qty_per_unit) + qty = req_qty_each * flt(self.doc.fg_completed_qty) + if qty <= 0: + return + item_args = self.get_item_dict(item) + item_args.update( + { + "conversion_factor": 1, + "s_warehouse": wo.wip_warehouse or item.source_warehouse, + "uom": item.stock_uom, + "qty": ceil_qty_if_uom_has_whole_number(qty, item.stock_uom), + } + ) + item_args["transfer_qty"] = item_args["qty"] + self.doc.append("items", item_args) + + def add_raw_materials_based_on_work_order(self): + bom_items = ( + self.wo_doc.get("required_items") + if self.wo_doc + else get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom) + ) + alternative_items = self.get_alternative_items(bom_items) + for row in bom_items: + self._append_wo_raw_material(row, alternative_items) + + def _append_wo_raw_material(self, row, alternative_items): + item_args = self.get_item_dict(row) + item_args.update( + { + "conversion_factor": 1, + "item_group": row.get("item_group"), + "s_warehouse": self._resolve_rm_warehouse(row), + "uom": row.stock_uom, + } + ) + qty = ( + (row.required_qty / self.wo_doc.qty) * self.doc.fg_completed_qty + if self.wo_doc + else flt(row.qty) * self.doc.fg_completed_qty + ) + item_args["qty"] = ceil_qty_if_uom_has_whole_number(qty, row.stock_uom) + item_args["transfer_qty"] = item_args["qty"] + if alt := alternative_items.get(row.item_code): + self.set_alternative_item_details(item_args, alt) + self.doc.append("items", item_args) + + def _resolve_rm_warehouse(self, row): + if self.doc.from_warehouse: + return self.doc.from_warehouse + if self.wo_doc.from_wip_warehouse: + return self.wo_doc.wip_warehouse + return row.get("source_warehouse") + + def get_alternative_items(self, bom_items): + item_codes_in_bom = [row.item_code for row in bom_items] + data = self._query_alternative_items(item_codes_in_bom) + if not data: + return frappe._dict() + return self._index_alternative_items(data) + + def _query_alternative_items(self, item_codes_in_bom): + doctype = frappe.qb.DocType("Stock Entry") + child_doc = frappe.qb.DocType("Stock Entry Detail") + query = ( + frappe.qb.from_(child_doc) + .inner_join(doctype) + .on(child_doc.parent == doctype.name) + .select( + child_doc.item_code, + child_doc.uom, + child_doc.stock_uom, + child_doc.conversion_factor, + child_doc.item_name, + child_doc.item_group, + child_doc.description, + child_doc.original_item, + ) + .where( + (doctype.work_order == self.doc.work_order) + & (doctype.purpose == "Material Transfer for Manufacture") + & (doctype.docstatus == 1) + ) + ) + if item_codes_in_bom: + query = query.where(child_doc.original_item.isin(item_codes_in_bom)) + return query.run(as_dict=1) + + def _index_alternative_items(self, data): + alternative_items = frappe._dict() + for row in data: + alternative_items[row.original_item] = row + alternative_items[row.original_item].original_item = None + return alternative_items + + def set_alternative_item_details(self, row, alternative_item_details): + if self.doc.work_order and row.get("allow_alternative_item") is None: + row["allow_alternative_item"] = self.wo_doc.allow_alternative_item + + if row["allow_alternative_item"]: + original_item = row["item_code"] + row.update(alternative_item_details) + row["original_item"] = original_item + + def add_raw_materials_based_on_transfer(self): + self.prepare_available_materials_based_on_transfer() + pending_qty_to_mfg = flt(self.doc.fg_completed_qty) + if self.doc.work_order: + pending_qty_to_mfg = flt(self.wo_doc.material_transferred_for_manufacturing) - flt( + self.wo_doc.produced_qty + ) + if pending_qty_to_mfg <= 0 and not self.doc.get("is_return"): + return + for key in self.available_materials: + self._append_transfer_based_rm(self.available_materials[key], pending_qty_to_mfg) + + def _append_transfer_based_rm(self, row, pending_qty_to_mfg): + item_args = self.get_item_dict(row) + is_return = self.doc.get("is_return") + qty = row.qty if is_return else (flt(row.qty) * flt(self.doc.fg_completed_qty)) / pending_qty_to_mfg + item_args["qty"] = ceil_qty_if_uom_has_whole_number(qty, row.uom) + item_args["transfer_qty"] = item_args["qty"] + if is_return: + item_args["s_warehouse"], item_args["t_warehouse"] = row.s_warehouse, row.t_warehouse + else: + item_args["t_warehouse"], item_args["s_warehouse"] = None, row.warehouse + if row.serial_nos or row.batches: + self.assign_serial_batches_to_materials(item_args, row, qty) + else: + self.doc.append("items", item_args) + + def assign_serial_batches_to_materials(self, item_args, row, qty): + if row.serial_nos: + self._append_with_serial_nos(item_args, row, qty) + elif len(row.batches) == 1: + self._append_with_single_batch(item_args, row) + elif row.batches: + self.split_items_based_on_batches(qty, item_args, row) + + def _append_with_serial_nos(self, item_args, row, qty): + if serial_nos := row.serial_nos[: cint(qty)]: + item_args["serial_no"] = "\n".join(serial_nos) + if not item_args.get("uom"): + item_args["uom"] = row.stock_uom + item_args["use_serial_batch_fields"] = 1 + self.doc.append("items", item_args) + + def _append_with_single_batch(self, item_args, row): + item_args["batch_no"] = next(iter(row.batches.keys())) + if not item_args.get("uom"): + item_args["uom"] = row.stock_uom + item_args["use_serial_batch_fields"] = 1 + self.doc.append("items", item_args) + + def split_items_based_on_batches(self, qty, item_args, row): + for batch_no, batch_qty in row.batches.items(): + if qty <= 0: + return + qty = self._append_batch_split_item(item_args, row, batch_no, batch_qty, qty) + + def _append_batch_split_item(self, item_args, row, batch_no, batch_qty, qty): + if batch_qty >= qty: + item_args["qty"], qty = qty, 0 + else: + item_args["qty"] = batch_qty + qty -= batch_qty + row.batches[batch_no] -= batch_qty + if not item_args.get("uom"): + item_args["uom"] = row.stock_uom + item_args["batch_no"] = batch_no + item_args["transfer_qty"] = item_args["qty"] + item_args["use_serial_batch_fields"] = 1 + self.doc.append("items", item_args) + return qty + + def prepare_available_materials_based_on_transfer(self): + self.available_materials = frappe._dict() + self._transfer_entries = self.get_transfer_entries() + if not self._transfer_entries: + return + + self.add_materials_from_transfer() + self._consumption_entries = self.get_consumption_entries() + if not self._consumption_entries: + return + + self.remove_consumed_materials_from_available() + + def return_available_materials_in_source_wh(self): + for row in self.doc.items: + row.s_warehouse, row.t_warehouse = row.t_warehouse, row.s_warehouse + + def get_transfer_entries(self): + stock_entry = frappe.qb.DocType("Stock Entry") + stock_entry_detail = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(stock_entry) + .inner_join(stock_entry_detail) + .on(stock_entry.name == stock_entry_detail.parent) + .select(stock_entry_detail.star) + .where( + (stock_entry.work_order == self.doc.work_order) + & (stock_entry.purpose == "Material Transfer for Manufacture") + & (stock_entry.docstatus == 1) + ) + .orderby(stock_entry_detail.idx) + ).run(as_dict=1) + + def add_materials_from_transfer(self): + for row in self._transfer_entries: + row.warehouse = row.t_warehouse + key = (row.item_code, row.warehouse) + if key not in self.available_materials: + self.available_materials[key] = frappe._dict(row) + else: + self.available_materials[key].qty += row.qty + + if row.serial_and_batch_bundle: + self.available_materials[key].update(self.get_sabb_details(row.serial_and_batch_bundle)) + + def get_consumption_entries(self): + stock_entry = frappe.qb.DocType("Stock Entry") + stock_entry_detail = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(stock_entry) + .inner_join(stock_entry_detail) + .on(stock_entry.name == stock_entry_detail.parent) + .select(stock_entry_detail.star) + .where( + (stock_entry.work_order == self.doc.work_order) + & (stock_entry_detail.s_warehouse.isnotnull()) + & (stock_entry.purpose == "Manufacture") + & (stock_entry.docstatus == 1) + ) + .orderby(stock_entry_detail.idx) + ).run(as_dict=1) + + def remove_consumed_materials_from_available(self): + for row in self._consumption_entries: + row.warehouse = row.s_warehouse + key = (row.item_code, row.warehouse) + self.available_materials[key].qty -= row.qty + if row.serial_and_batch_bundle: + self._deduct_consumed_serial_batch(key, row.serial_and_batch_bundle) + + def _deduct_consumed_serial_batch(self, key, sabb_name): + _details = self.get_sabb_details(sabb_name) + if _details.serial_nos: + for sn in _details.serial_nos: + self.available_materials[key].serial_nos.remove(sn) + elif _details.batches: + for batch_no, qty in _details.batches.items(): + # qty is negative, so add instead of subtract + self.available_materials[key].batches[batch_no] += qty + + def add_additional_cost(self): + if not self.wo_doc: + return + + add_additional_cost(self.doc, self.wo_doc) + + def add_secondary_items_from_job_card(self): + if not self.wo_doc: + return + + secondary_items = self.get_secondary_items_from_job_card() + for row in secondary_items: + row.uom = row.uom or row.stock_uom + row.qty = ceil_qty_if_uom_has_whole_number(row.stock_qty, row.stock_uom) + row.transfer_qty = row.qty + row.s_warehouse = None + row.t_warehouse = row.warehouse or self.doc.to_warehouse + row.is_legacy_scrap_item = row.is_legacy + row.secondary_item_type = row.get("secondary_item_type") + + self.doc.append("items", row) + + def get_secondary_items_from_job_card(self): + if not self.wo_doc.operations: + return [] + secondary_items = get_secondary_items_from_job_card(self.doc.work_order, self.doc.job_card) + pending_qty = self._get_pending_secondary_qty() + used_secondary_items = self.get_used_secondary_items() + self._adjust_secondary_item_qtys(secondary_items, used_secondary_items, pending_qty) + return secondary_items + + def _get_pending_secondary_qty(self): + if self.doc.job_card: + return flt(self.doc.fg_completed_qty) + return flt(self.get_completed_job_card_qty()) - flt(self.wo_doc.produced_qty) + + def _adjust_secondary_item_qtys(self, secondary_items, used_secondary_items, pending_qty): + for row in secondary_items: + row.stock_qty -= flt(used_secondary_items.get(row.item_code)) + row.stock_qty = row.stock_qty * flt(self.doc.fg_completed_qty) / flt(pending_qty) + if used_secondary_items.get(row.item_code): + used_secondary_items[row.item_code] -= row.stock_qty + + def get_used_secondary_items(self): + data = self._query_used_secondary_items() + used_secondary_items = defaultdict(float) + for row in data: + used_secondary_items[row.item_code] += row.qty + return used_secondary_items + + def _query_used_secondary_items(self): + se = frappe.qb.DocType("Stock Entry") + sed = frappe.qb.DocType("Stock Entry Detail") + return ( + frappe.qb.from_(se) + .inner_join(sed) + .on(sed.parent == se.name) + .select(sed.item_code, sed.qty) + .where( + (se.work_order == self.doc.work_order) + & ((sed.secondary_item_type.isnotnull()) | (sed.is_legacy_scrap_item == 1)) + & (se.docstatus == 1) + & (se.purpose.isin(["Repack", "Manufacture"])) + ) + ).run(as_dict=1) + + def get_completed_job_card_qty(self): + return flt(min([d.completed_qty for d in self.wo_doc.operations])) + + def get_sabb_details(self, sabb): + sabb_entries = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": sabb, "docstatus": 1, "is_cancelled": 0}, + fields=["serial_no", "batch_no", "qty"], + order_by="idx", + ) + + serial_nos = [] + batches = defaultdict(float) + + for row in sabb_entries: + if row.serial_no: + serial_nos.append(row.serial_no) + else: + batches[row.batch_no] += row.qty + + return frappe._dict({"serial_nos": serial_nos, "batches": batches}) + + def on_submit(self): + self.update_job_card_and_work_order() + + def on_cancel(self): + self.update_job_card_and_work_order() + + def update_job_card_and_work_order(self): + if self.doc.job_card: + self._update_job_card_on_manufacture() + if self.doc.work_order: + self._update_work_order_on_manufacture() + + def _update_job_card_on_manufacture(self): + job_doc = frappe.get_doc("Job Card", self.doc.job_card) + job_doc.set_consumed_qty_in_job_card_item(self.doc) + job_doc.set_manufactured_qty() + job_doc.update_work_order() + + def _update_work_order_on_manufacture(self): + self._validate_work_order() + if self.doc.fg_completed_qty: + self.wo_doc.run_method("update_work_order_qty") + self.wo_doc.run_method("update_planned_qty") + self.wo_doc.run_method("update_status") + if not self.wo_doc.operations: + self.wo_doc.set_actual_dates() + + +class RepackStockEntry(BaseManufactureStockEntry): + def before_validate(self): + self.set_default_warehouse() + + def validate(self): + self.validate_raw_materials_exists() + self.validate_repack_entry() + + def validate_repack_entry(self): + fg_items = {row.item_code: row for row in self.doc.items if row.is_finished_item} + + if len(fg_items) > 1 and not all(row.set_basic_rate_manually for row in fg_items.values()): + frappe.throw( + _( + "When there are multiple finished goods ({0}) in a Repack stock entry, the basic rate for all finished goods must be set manually. To set rate manually, enable the checkbox 'Set Basic Rate Manually' in the respective finished good row." + ).format(", ".join(fg_items)), + title=_("Set Basic Rate Manually"), + ) + + def add_items(self): + self.add_raw_materials_based_on_bom() + self.set_process_loss_qty() + self.add_finished_goods() + self.add_secondary_items() + + def add_raw_materials_based_on_bom(self): + bom_items = get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom) + + for row in bom_items: + row.s_warehouse = self.doc.from_warehouse + row.qty = row.qty * self.doc.fg_completed_qty + row.transfer_qty = row.qty + if not row.uom: + row.uom = row.stock_uom + + self.doc.append("items", row) + + +class MaterialConsumptionForManufactureStockEntry(ManufactureStockEntry): + def before_validate(self): + self.set_default_warehouse() + + def validate(self): + self.validate_work_order() + + def add_items(self): + if self.backflush_based_on == "BOM" or self.wo_doc.skip_transfer: + self.add_raw_materials_based_on_work_order() + else: + self.add_raw_materials_based_on_transfer() + + +def get_production_item_details(work_order=None, bom_no=None): + production_item = ( + frappe.get_cached_value("Work Order", work_order, "production_item") + if work_order + else frappe.get_cached_value("BOM", bom_no, "item") + ) + return frappe.get_cached_value( + "Item", + production_item, + ["item_name", "item_group", "description", "stock_uom", "name"], + as_dict=1, + ) + + +def _check_bom_component_qty(doc, bom_items): + """Validate that stock entry items match BOM quantities.""" + precision = frappe.get_precision("Stock Entry Detail", "qty") + for row in bom_items: + row.qty = row.qty * doc.fg_completed_qty + matched_item = next( + ( + item + for item in doc.items + if item.s_warehouse + and (item.item_code == row.item_code or item.original_item == row.item_code) + ), + None, + ) + if matched_item: + if flt(row.qty, precision) != flt(matched_item.qty, precision): + frappe.throw( + _( + "For the item {0}, the consumed quantity should be {1} according to the BOM {2}." + ).format( + bold(row.item_code), + flt(row.qty), + get_link_to_form("BOM", doc.bom_no), + ), + title=_("Incorrect Component Quantity"), + ) + else: + frappe.throw( + _("According to the BOM {0}, the Item '{1}' is missing in the stock entry.").format( + get_link_to_form("BOM", doc.bom_no), bold(row.item_code) + ), + title=_("Missing Item"), + ) + + +def get_bom_items(bom_no, use_multi_level_bom=None, qty=None, fetch_secondary_items=False): + if use_multi_level_bom is None: + use_multi_level_bom = frappe.get_cached_value("BOM", bom_no, "use_multi_level_bom") + qty = qty or 1 + + if fetch_secondary_items: + table_name = "BOM Secondary Item" + else: + table_name = "BOM Explosion Item" if use_multi_level_bom else "BOM Item" + + items = _run_bom_items_query(bom_no, table_name, qty) + return _deduplicate_bom_items(items) + + +def _run_bom_items_query(bom_no, table_name, qty): + bom_doc = frappe.qb.DocType("BOM") + doctype = frappe.qb.DocType(table_name) + query = ( + frappe.qb.from_(doctype) + .inner_join(bom_doc) + .on(doctype.parent == bom_doc.name) + .select( + doctype.item_code, + doctype.item_name, + doctype.stock_uom, + doctype.description, + (doctype.stock_qty / bom_doc.quantity.as_("qty") * qty).as_("qty"), + doctype.rate.as_("basic_rate"), + ) + .where((bom_doc.name == bom_no) & (bom_doc.docstatus == 1)) + .orderby(doctype.idx) + ) + return _add_bom_table_specific_fields(query, doctype, table_name).run(as_dict=1) + + +def _add_bom_table_specific_fields(query, doctype, table_name): + if table_name == "BOM Secondary Item": + return query.select( + doctype.name, + doctype.cost_allocation_per, + doctype.uom, + doctype.process_loss_per, + doctype.secondary_item_type, + doctype.is_legacy, + doctype.conversion_factor, + ) + if table_name == "BOM Item": + return query.select( + doctype.allow_alternative_item, doctype.uom, doctype.conversion_factor, doctype.bom_no + ) + return query + + +def _deduplicate_bom_items(items): + item_dict = {} + for item in items: + if item.item_code in item_dict: + item_dict[item.item_code].qty += item.qty + else: + item_dict[item.item_code] = item + return list(item_dict.values()) + + +def get_secondary_items(bom_no, work_order=None): + if ( + frappe.db.get_single_value( + "Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies" + ) + and work_order + and frappe.get_cached_value("Work Order", work_order, "use_multi_level_bom") + ): + return get_secondary_items_from_sub_assemblies(bom_no) + else: + return get_bom_items(bom_no, fetch_secondary_items=True) + + +def get_secondary_items_from_sub_assemblies(bom_no): + items = [] + bom_items = get_bom_items(bom_no) + for row in bom_items: + if not row.bom_no: + continue + + items.extend(get_bom_items(row.bom_no, qty=row.qty, fetch_secondary_items=True)) + items.extend(get_secondary_items_from_sub_assemblies(row.bom_no)) + + return items + + +def get_secondary_items_from_job_card(work_order, jc_name=None): + job_card = frappe.qb.DocType("Job Card") + job_card_secondary_item = frappe.qb.DocType("Job Card Secondary Item") + + secondary_items = ( + frappe.qb.from_(job_card) + .select( + 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.secondary_item_type, + job_card_secondary_item.bom_secondary_item, + ) + .join(job_card_secondary_item) + .on(job_card_secondary_item.parent == job_card.name) + .where( + (job_card_secondary_item.item_code.isnotnull()) + & (job_card.work_order == work_order) + & (job_card.docstatus == 1) + ) + .groupby(job_card_secondary_item.item_code, job_card_secondary_item.secondary_item_type) + .orderby(job_card_secondary_item.idx) + ) + + if jc_name: + secondary_items = secondary_items.where(job_card.name == jc_name) + + return secondary_items.run(as_dict=1) + + +def ceil_qty_if_uom_has_whole_number(qty, stock_uom): + if cint(frappe.get_cached_value("UOM", stock_uom, "must_be_whole_number")): + qty = ceil(qty) + + return qty + + +@frappe.whitelist() +def move_sample_to_retention_warehouse(company: str, items: str | list): + if isinstance(items, str): + items = json.loads(items) + + retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse") + stock_entry = frappe.new_doc("Stock Entry") + stock_entry.company = company + stock_entry.purpose = "Material Transfer" + stock_entry.set_stock_entry_type() + + for item in items: + if item.get("sample_quantity") and item.get("serial_and_batch_bundle"): + _process_sample_item(stock_entry, item, retention_warehouse) + + if stock_entry.get("items"): + return stock_entry.as_dict() + + +def _process_sample_item(stock_entry, item, retention_warehouse): + warehouse = item.get("t_warehouse") or item.get("warehouse") + sabb = _duplicate_sample_bundle(item, warehouse) + total_qty, sabe_list = _collect_sample_batches(sabb, item, warehouse) + if total_qty: + _append_sample_entry(stock_entry, sabb, item, warehouse, retention_warehouse, total_qty, sabe_list) + + +def _duplicate_sample_bundle(item, warehouse): + return SerialBatchCreation( + { + "type_of_transaction": "Outward", + "serial_and_batch_bundle": item.get("serial_and_batch_bundle"), + "item_code": item.get("item_code"), + "warehouse": warehouse, + "do_not_save": True, + } + ).duplicate_package() + + +def _collect_sample_batches(sabb, item, warehouse): + batches = get_batch_nos(item.get("serial_and_batch_bundle")) + sabe_list, total_qty = [], 0 + for batch_no in batches.keys(): + qty, entries = _process_sample_batch(sabb, item, warehouse, batch_no) + total_qty += qty + sabe_list.extend(entries) + return total_qty, sabe_list + + +def _process_sample_batch(sabb, item, warehouse, batch_no): + sample_quantity = validate_sample_quantity( + item.get("item_code"), + item.get("sample_quantity"), + item.get("transfer_qty") or item.get("qty"), + batch_no, + ) + sabe = next(entry for entry in sabb.entries if entry.batch_no == batch_no) + if not sample_quantity: + sabb.entries.remove(sabe) + return 0, [] + return _apply_sample_quantity(sabb, sabe, warehouse, batch_no, sample_quantity) + + +def _apply_sample_quantity(sabb, sabe, warehouse, batch_no, sample_quantity): + if sabb.has_serial_no: + entries = [ + e + for e in sabb.entries + if e.batch_no == batch_no + and frappe.db.exists("Serial No", {"name": e.serial_no, "warehouse": warehouse}) + ][: int(sample_quantity)] + return len(entries), entries + sabe.qty = sample_quantity + return sample_quantity, [] + + +def _append_sample_entry(stock_entry, sabb, item, warehouse, retention_warehouse, total_qty, sabe_list): + if sabe_list: + sabb.entries = sabe_list + sabb.save() + stock_entry.append( + "items", + { + "item_code": item.get("item_code"), + "s_warehouse": warehouse, + "t_warehouse": retention_warehouse, + "qty": total_qty, + "basic_rate": item.get("valuation_rate"), + "uom": item.get("uom"), + "stock_uom": item.get("stock_uom"), + "conversion_factor": item.get("conversion_factor") or 1.0, + "serial_and_batch_bundle": sabb.name, + }, + ) + + +@frappe.whitelist() +def validate_sample_quantity(item_code: str, sample_quantity: int, qty: float, batch_no: str | None = None): + from erpnext.stock.doctype.batch.batch import get_batch_qty + + if cint(qty) < cint(sample_quantity): + frappe.throw( + _("Sample quantity {0} cannot be more than received quantity {1}").format(sample_quantity, qty) + ) + return _adjust_sample_quantity(item_code, sample_quantity, batch_no, get_batch_qty) + + +def _adjust_sample_quantity(item_code, sample_quantity, batch_no, get_batch_qty): + retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse") + retainted_qty = get_batch_qty(batch_no, retention_warehouse, item_code) if batch_no else 0 + max_retain_qty = frappe.get_value("Item", item_code, "sample_quantity") + if retainted_qty >= max_retain_qty: + _warn_max_retained(retainted_qty, batch_no, item_code) + return 0 + return _cap_sample_quantity(sample_quantity, max_retain_qty, retainted_qty, batch_no, item_code) + + +def _warn_max_retained(retainted_qty, batch_no, item_code): + frappe.msgprint( + _("Maximum Samples - {0} have already been retained for Batch {1} and Item {2} in Batch {3}.").format( + retainted_qty, batch_no, item_code, batch_no + ), + alert=True, + ) + + +def _cap_sample_quantity(sample_quantity, max_retain_qty, retainted_qty, batch_no, item_code): + qty_diff = max_retain_qty - retainted_qty + if cint(sample_quantity) > cint(qty_diff): + frappe.msgprint( + _("Maximum Samples - {0} can be retained for Batch {1} and Item {2}.").format( + max_retain_qty, batch_no, item_code + ), + alert=True, + ) + return qty_diff + return sample_quantity diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index b102e20cfc4..2dcc3c6cbb3 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -909,7 +909,14 @@ 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 +<<<<<<< HEAD secondary_item_cost = next(filter(lambda x: x.type or x.is_legacy_scrap_item, s.get("items"))).amount +======= + secondary_item_cost = next( + filter(lambda x: x.secondary_item_type or x.is_legacy_scrap_item, s.get("items")) + ).amount + +>>>>>>> dbcfac839c (chore: rename type field to secondary_item_type (#55469)) self.assertEqual(fg_cost, flt(rm_cost - secondary_item_cost, 2)) # When Stock Entry has only FG + Scrap @@ -1027,7 +1034,7 @@ class TestStockEntry(ERPNextTestSuite): basic_rate=row.basic_rate or 100, ) - if row.type or row.is_legacy_scrap_item: + if row.secondary_item_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") @@ -1035,10 +1042,16 @@ class TestStockEntry(ERPNextTestSuite): stock_entry.inspection_required = 1 stock_entry.save() - self.assertTrue([row.item_code for row in stock_entry.items if row.type or row.is_legacy_scrap_item]) + self.assertTrue( + [ + row.item_code + for row in stock_entry.items + if row.secondary_item_type or row.is_legacy_scrap_item + ] + ) for row in stock_entry.items: - if not row.type and not row.is_legacy_scrap_item: + if not row.secondary_item_type and not row.is_legacy_scrap_item: qc = frappe.get_doc( { "doctype": "Quality Inspection", @@ -1058,7 +1071,7 @@ class TestStockEntry(ERPNextTestSuite): stock_entry.reload() stock_entry.submit() for row in stock_entry.items: - if row.type or row.is_legacy_scrap_item: + if row.secondary_item_type or row.is_legacy_scrap_item: self.assertFalse(row.quality_inspection) else: self.assertTrue(row.quality_inspection) 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 b446aa1e51e..c21d9ec91cb 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -19,7 +19,7 @@ "col_break2", "is_finished_item", "is_legacy_scrap_item", - "type", + "secondary_item_type", "quality_inspection", "subcontracted_item", "against_fg", @@ -559,7 +559,7 @@ }, { "default": "0", - "depends_on": "eval:!doc.is_legacy_scrap_item && !doc.type", + "depends_on": "eval:!doc.is_legacy_scrap_item && !doc.secondary_item_type", "fieldname": "is_finished_item", "fieldtype": "Check", "label": "Is Finished Item", @@ -653,7 +653,7 @@ }, { "depends_on": "eval:parent.purpose == \"Manufacture\" && doc.t_warehouse && !doc.is_finished_item && !doc.is_legacy_scrap_item", - "fieldname": "type", + "fieldname": "secondary_item_type", "fieldtype": "Select", "label": "Type", "options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good" @@ -679,7 +679,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-04-27 11:40:38.294196", + "modified": "2026-06-01 10:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", 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 0c1a21fefce..a8c3ff44ddc 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py @@ -67,7 +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"] + secondary_item_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/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py index aea08e18b34..5d2d47759e7 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py @@ -492,7 +492,7 @@ class SubcontractingInwardOrder(SubcontractingController): "from_warehouse": secondary_item.warehouse, "stock_uom": secondary_item.stock_uom, "scio_detail": secondary_item.name, - "type": secondary_item.type, + "secondary_item_type": secondary_item.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 d035f4ddcb9..28537608b4c 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 @@ -327,7 +327,7 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite): def test_secondary_items_delivery(self): new_bom = frappe.copy_doc(frappe.get_doc("BOM", "BOM-Basic FG Item-001")) new_bom.secondary_items.append( - frappe.new_doc("BOM Secondary Item", item_code="Basic RM 2", qty=1, type="Scrap") + frappe.new_doc("BOM Secondary Item", item_code="Basic RM 2", qty=1, secondary_item_type="Scrap") ) new_bom.submit() sc_bom = frappe.get_doc("Subcontracting BOM", "SB-0001") diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.json b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.json index 94a640b41ce..01e4c63ad2f 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.json @@ -7,7 +7,7 @@ "engine": "InnoDB", "field_order": [ "column_break_rptg", - "type", + "secondary_item_type", "reference_name", "column_break_jkzt", "item_code", @@ -97,7 +97,7 @@ "fieldtype": "Column Break" }, { - "fieldname": "type", + "fieldname": "secondary_item_type", "fieldtype": "Select", "label": "Type", "no_copy": 1, @@ -114,7 +114,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-02-27 15:15:40.009957", + "modified": "2026-06-01 10:00:00.000000", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Inward Order Secondary Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.py index 767f216921a..9fcc8b20135 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.py @@ -23,7 +23,7 @@ class SubcontractingInwardOrderSecondaryItem(Document): produced_qty: DF.Float reference_name: DF.Data stock_uom: DF.Link - type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"] + secondary_item_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_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 23a3830c951..9867900bd2c 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -419,7 +419,7 @@ class SubcontractingReceipt(SubcontractingController): self.append( "items", { - "type": secondary_item.type, + "secondary_item_type": secondary_item.secondary_item_type, "is_legacy_scrap_item": secondary_item.is_legacy, "reference_name": item.name, "item_code": secondary_item.item_code, @@ -447,7 +447,7 @@ class SubcontractingReceipt(SubcontractingController): def remove_secondary_items(self): for item in list(self.items): - if item.type or item.is_legacy_scrap_item: + if item.secondary_item_type or item.is_legacy_scrap_item: self.remove(item) else: item.secondary_items_cost_per_qty = 0 @@ -507,7 +507,7 @@ class SubcontractingReceipt(SubcontractingController): secondary_items_cost_map = {} for item in self.get("items") or []: - if item.type or item.is_legacy_scrap_item: + if item.secondary_item_type or item.is_legacy_scrap_item: qty = ( flt(item.qty) if item.is_legacy_scrap_item @@ -522,7 +522,7 @@ class SubcontractingReceipt(SubcontractingController): total_qty = total_amount = 0 for item in self.get("items") or []: - if not item.type and not item.is_legacy_scrap_item: + if not item.secondary_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] @@ -566,7 +566,7 @@ class SubcontractingReceipt(SubcontractingController): def validate_secondary_items(self): for item in self.items: - if item.type or item.is_legacy_scrap_item: + if item.secondary_item_type or item.is_legacy_scrap_item: if not item.qty: frappe.throw( _("Row #{0}: Secondary Item Qty cannot be zero").format(item.idx), diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index 7105eca2e13..8963f787646 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -1221,7 +1221,7 @@ class TestSubcontractingReceipt(ERPNextTestSuite): scr.get_secondary_items() scr_secondary_items = set( - [item.item_code for item in scr.items if item.type or item.is_legacy_scrap_item] + [item.item_code for item in scr.items if item.secondary_item_type or item.is_legacy_scrap_item] ) self.assertEqual(len(scr.items), 3) # 1 FG Item + 2 Scrap Items self.assertEqual(scr_secondary_items, set(secondary_items)) 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 b6d07f66b98..71f262d7663 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json @@ -9,7 +9,7 @@ "field_order": [ "item_code", "is_legacy_scrap_item", - "type", + "secondary_item_type", "column_break_2", "item_name", "section_break_4", @@ -162,12 +162,12 @@ "label": "Accepted Qty", "no_copy": 1, "print_width": "100px", - "read_only_depends_on": "eval:doc.type || doc.is_legacy_scrap_item", + "read_only_depends_on": "eval:doc.secondary_item_type || doc.is_legacy_scrap_item", "width": "100px" }, { "columns": 1, - "depends_on": "eval:!parent.is_return && !doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!parent.is_return && !doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "rejected_qty", "fieldtype": "Float", "in_list_view": 1, @@ -175,7 +175,7 @@ "no_copy": 1, "print_hide": 1, "print_width": "100px", - "read_only_depends_on": "eval:doc.type || doc.is_legacy_scrap_item", + "read_only_depends_on": "eval:doc.secondary_item_type || doc.is_legacy_scrap_item", "width": "100px" }, { @@ -234,7 +234,7 @@ }, { "default": "0", - "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "rm_cost_per_qty", "fieldtype": "Currency", "label": "Raw Material Cost Per Qty", @@ -244,7 +244,7 @@ }, { "default": "0", - "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "service_cost_per_qty", "fieldtype": "Currency", "label": "Service Cost Per Qty", @@ -254,7 +254,7 @@ }, { "default": "0", - "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "additional_cost_per_qty", "fieldtype": "Currency", "label": "Additional Cost Per Qty", @@ -278,7 +278,7 @@ "width": "100px" }, { - "depends_on": "eval: !parent.is_return && !doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval: !parent.is_return && !doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "rejected_warehouse", "fieldtype": "Link", "ignore_user_permissions": 1, @@ -290,7 +290,7 @@ "width": "100px" }, { - "depends_on": "eval:!doc.__islocal && !doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!doc.__islocal && !doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "quality_inspection", "fieldtype": "Link", "label": "Quality Inspection", @@ -372,7 +372,7 @@ "no_copy": 1, "options": "BOM", "print_hide": 1, - "read_only_depends_on": "eval:doc.type || doc.is_legacy_scrap_item" + "read_only_depends_on": "eval:doc.secondary_item_type || doc.is_legacy_scrap_item" }, { "fetch_from": "item_code.brand", @@ -499,7 +499,7 @@ "print_hide": 1 }, { - "depends_on": "eval:(doc.use_serial_batch_fields === 0 || doc.docstatus === 1) && !doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:(doc.use_serial_batch_fields === 0 || doc.docstatus === 1) && !doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "rejected_serial_and_batch_bundle", "fieldtype": "Link", "label": "Rejected Serial and Batch Bundle", @@ -564,7 +564,7 @@ "label": "Add Serial / Batch Bundle" }, { - "depends_on": "eval:doc.use_serial_batch_fields === 0 && !doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:doc.use_serial_batch_fields === 0 && !doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "add_serial_batch_for_rejected_qty", "fieldtype": "Button", "label": "Add Serial / Batch No (Rejected Qty)" @@ -578,7 +578,7 @@ "search_index": 1 }, { - "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "landed_cost_voucher_amount", "fieldtype": "Currency", "label": "Landed Cost Voucher Amount", @@ -596,7 +596,7 @@ "options": "Account" }, { - "fieldname": "type", + "fieldname": "secondary_item_type", "fieldtype": "Select", "label": "Type", "no_copy": 1, @@ -606,7 +606,7 @@ }, { "default": "0", - "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "secondary_items_cost_per_qty", "fieldtype": "Currency", "label": "Secondary Items Cost Per Qty", @@ -635,7 +635,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-03-09 15:11:16.977539", + "modified": "2026-06-01 10:00:00.000000", "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 c6233b841a2..47cfd9a1648 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py @@ -62,7 +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"] + secondary_item_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