From d07f4bb8578a42631dbbcd33465941360ef4e27c Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Wed, 19 Aug 2026 17:03:20 +0530 Subject: [PATCH] feat: percentage based BOM (#58292) * feat: percentage based BOM, fixed qty components and component qty tolerance * fix: mandatory percentages, fixed qty explosion keys and expected qty aggregation * fix: percentage BOM incompatible with semi FG tracking, fixed qty in production plan explosion * refactor: remove component_qty_tolerance from Manufacturing Settings * refactor: drop fixed qty components and consumption tolerance, keep percentage-based BOM only --- erpnext/manufacturing/doctype/bom/bom.js | 14 ++++ erpnext/manufacturing/doctype/bom/bom.json | 12 ++- erpnext/manufacturing/doctype/bom/bom.py | 78 +++++++++++++++++++ erpnext/manufacturing/doctype/bom/test_bom.py | 46 +++++++++++ .../doctype/bom_item/bom_item.json | 24 +++++- .../doctype/bom_item/bom_item.py | 2 + 6 files changed, 173 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 17e9e2caeec..82297245663 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -172,6 +172,19 @@ frappe.ui.form.on("BOM", { frm.fields_dict["operations"].grid.reset_grid(); }, + toggle_percentage_field(frm) { + let show = Boolean(frm.doc.set_qty_based_on_percentage); + + frm.fields_dict["items"].grid.update_docfield_property("percentage", "in_list_view", show); + frm.fields_dict["items"].grid.update_docfield_property("percentage", "hidden", !show); + frm.fields_dict["items"].grid.update_docfield_property("qty", "read_only", show); + frm.fields_dict["items"].grid.reset_grid(); + }, + + set_qty_based_on_percentage(frm) { + frm.trigger("toggle_percentage_field"); + }, + with_operations: function (frm) { frm.set_df_property("fg_based_operating_cost", "hidden", frm.doc.with_operations ? 1 : 0); frm.trigger("toggle_fields_for_semi_finished_goods"); @@ -213,6 +226,7 @@ frappe.ui.form.on("BOM", { frm.toggle_enable("item", frm.doc.__islocal); frm.trigger("toggle_fields_for_semi_finished_goods"); + frm.trigger("toggle_percentage_field"); frm.set_indicator_formatter("item_code", function (doc) { if (doc.original_item) { diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index a322c61e0e5..003c6e0b3db 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -13,6 +13,7 @@ "column_break_ztxc", "quantity", "uom", + "set_qty_based_on_percentage", "cost_allocation__process_loss_section", "cost_allocation_per", "cost_allocation", @@ -143,6 +144,15 @@ "options": "UOM", "read_only": 1 }, + { + "default": "0", + "depends_on": "item", + "description": "Component quantities are derived from their percentage of the Output Qty. One component row can be marked as Balance Item to absorb the remaining percentage.", + "fieldname": "set_qty_based_on_percentage", + "fieldtype": "Check", + "label": "Set Component Quantities Based On Percentage", + "show_description_on_click": 1 + }, { "default": "1", "depends_on": "item", @@ -761,7 +771,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2026-04-17 15:22:33.598938", + "modified": "2026-08-19 14:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index fb4884e33c4..5222dd796de 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -184,6 +184,7 @@ class BOM(WebsiteGenerator): routing: DF.Link | None secondary_items: DF.Table[BOMSecondaryItem] secondary_items_cost: DF.Currency + set_qty_based_on_percentage: DF.Check set_rate_of_sub_assembly_item_based_on_bom: DF.Check show_in_website: DF.Check show_items: DF.Check @@ -321,6 +322,7 @@ class BOM(WebsiteGenerator): self.validate_uom_is_interger() def _validate_materials_and_cost(self): + self.set_qty_from_percentage() self.set_bom_material_details() self.set_secondary_items_details() self.validate_materials() @@ -701,6 +703,82 @@ class BOM(WebsiteGenerator): if not self.quantity: frappe.throw(_("Quantity should be greater than 0")) + def set_qty_from_percentage(self): + if not self.set_qty_based_on_percentage or not self.get("items"): + return + + if self.track_semi_finished_goods: + frappe.throw( + _( + "'Set Component Quantities Based On Percentage' cannot be used together with 'Track Semi Finished Goods', as the component rows are derived from the operation BOMs." + ), + title=_("Invalid Formulation"), + ) + + percentage_rows = self.get("items") + for row in percentage_rows: + if not flt(row.percentage) and not row.is_balance_item: + frappe.throw( + _( + "Row #{0}: A Percentage is required for the Item {1} as 'Set Component Quantities Based On Percentage' is enabled." + ).format(row.idx, bold(row.item_code)), + title=_("Invalid Formulation"), + ) + + self._set_balance_item_percentage(percentage_rows) + self._validate_total_percentage(percentage_rows) + + for row in percentage_rows: + if not row.uom: + row.uom = frappe.get_cached_value("Item", row.item_code, "stock_uom") + + row.qty = flt( + flt(row.percentage) / 100 * flt(self.quantity) * self._uom_factor_from_batch_uom(row), + row.precision("qty"), + ) + + def _validate_total_percentage(self, percentage_rows): + total = sum(flt(row.percentage) for row in percentage_rows) + if abs(total - 100) > 0.0001: + frappe.throw( + _( + "The percentages of the components must total 100%. The current total is {0}%. To fill the remaining percentage automatically, mark one component as Balance Item." + ).format(flt(total)), + title=_("Invalid Formulation"), + ) + + def _set_balance_item_percentage(self, percentage_rows): + balance_rows = [row for row in percentage_rows if row.is_balance_item] + if not balance_rows: + return + + if len(balance_rows) > 1: + frappe.throw(_("Only one component can be marked as Balance Item.")) + + remaining = 100 - sum(flt(row.percentage) for row in percentage_rows if not row.is_balance_item) + if remaining <= 0: + frappe.throw( + _( + "The other components already total {0}%, so no percentage remains for the Balance Item {1}." + ).format(flt(100 - remaining), bold(balance_rows[0].item_code)), + title=_("Invalid Formulation"), + ) + + balance_rows[0].percentage = remaining + + def _uom_factor_from_batch_uom(self, row): + from erpnext.stock.doctype.item.item import get_uom_conv_factor + + factor = get_uom_conv_factor(self.uom, row.uom) + if not factor: + frappe.throw( + _( + "Row #{0}: The quantity of the Item {1} cannot be derived from its percentage because there is no UOM Conversion Factor from {2} to {3}." + ).format(row.idx, bold(row.item_code), bold(self.uom), bold(row.uom)) + ) + + return flt(factor) + def validate_currency(self): if self.rm_cost_as_per == "Price List": price_list_currency = frappe.db.get_value("Price List", self.buying_price_list, "currency") diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 76a806347bb..515708f6d80 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -1097,6 +1097,52 @@ class TestBOM(ERPNextTestSuite): any(row.item_code == rm_item and cint(row.operation_row_id) == 1 for row in bom.items) ) + @timeout + def test_percentage_based_component_quantities(self): + fg_item = make_item(properties={"is_stock_item": 1}).name + rm1 = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + rm2 = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + rm3 = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + + bom = frappe.new_doc("BOM") + bom.company = "_Test Company" + bom.item = fg_item + bom.quantity = 200 + bom.set_qty_based_on_percentage = 1 + bom.append("items", {"item_code": rm1, "percentage": 40, "qty": 1}) + bom.append("items", {"item_code": rm2, "percentage": 35, "qty": 1}) + bom.append("items", {"item_code": rm3, "is_balance_item": 1, "qty": 1}) + bom.insert() + + self.assertEqual(flt(bom.items[0].qty), 80) + self.assertEqual(flt(bom.items[1].qty), 70) + self.assertEqual(flt(bom.items[2].percentage), 25) + self.assertEqual(flt(bom.items[2].qty), 50) + + @timeout + def test_percentage_total_must_be_100(self): + fg_item = make_item(properties={"is_stock_item": 1}).name + rm1 = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + rm2 = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name + + bom = frappe.new_doc("BOM") + bom.company = "_Test Company" + bom.item = fg_item + bom.quantity = 100 + bom.set_qty_based_on_percentage = 1 + bom.append("items", {"item_code": rm1, "percentage": 40, "qty": 1}) + bom.append("items", {"item_code": rm2, "percentage": 30, "qty": 1}) + + self.assertRaises(frappe.ValidationError, bom.insert) + + bom.items[1].percentage = 0 + self.assertRaises(frappe.ValidationError, bom.insert) + + bom.items[1].percentage = 60 + bom.with_operations = 1 + bom.track_semi_finished_goods = 1 + self.assertRaisesRegex(frappe.ValidationError, "Track Semi Finished Goods", bom.insert) + @timeout def test_final_operation_must_produce_the_bom_item(self): from erpnext.manufacturing.doctype.operation.test_operation import make_operation diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.json b/erpnext/manufacturing/doctype/bom_item/bom_item.json index 12d5090ef0e..543731c1c1c 100644 --- a/erpnext/manufacturing/doctype/bom_item/bom_item.json +++ b/erpnext/manufacturing/doctype/bom_item/bom_item.json @@ -22,6 +22,7 @@ "image", "image_view", "quantity_and_rate", + "percentage", "qty", "uom", "col_break2", @@ -43,7 +44,8 @@ "column_break_33", "sourced_by_supplier", "is_sub_assembly_item", - "is_phantom_item" + "is_phantom_item", + "is_balance_item" ], "fields": [ { @@ -134,6 +136,15 @@ "fieldtype": "Section Break", "label": "Quantity and Rate" }, + { + "columns": 2, + "depends_on": "eval:parent.set_qty_based_on_percentage", + "fieldname": "percentage", + "fieldtype": "Percent", + "in_list_view": 1, + "label": "Percentage (%)", + "read_only_depends_on": "eval:doc.is_balance_item" + }, { "columns": 2, "fieldname": "qty", @@ -142,6 +153,7 @@ "label": "Qty", "oldfieldname": "qty", "oldfieldtype": "Currency", + "read_only_depends_on": "eval:parent.set_qty_based_on_percentage", "reqd": 1 }, { @@ -322,13 +334,21 @@ "fieldtype": "Check", "label": "Is Phantom Item", "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:parent.set_qty_based_on_percentage", + "description": "This component absorbs the percentage remaining after all other percentage rows", + "fieldname": "is_balance_item", + "fieldtype": "Check", + "label": "Is Balance Item" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-08-07 17:31:31.732720", + "modified": "2026-08-19 14:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Item", diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.py b/erpnext/manufacturing/doctype/bom_item/bom_item.py index 6f58edb24b0..9a2237af069 100644 --- a/erpnext/manufacturing/doctype/bom_item/bom_item.py +++ b/erpnext/manufacturing/doctype/bom_item/bom_item.py @@ -25,6 +25,7 @@ class BOMItem(Document): has_variants: DF.Check image: DF.Attach | None include_item_in_manufacturing: DF.Check + is_balance_item: DF.Check is_phantom_item: DF.Check is_stock_item: DF.Check is_sub_assembly_item: DF.Check @@ -36,6 +37,7 @@ class BOMItem(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data + percentage: DF.Percent qty: DF.Float qty_consumed_per_unit: DF.Float rate: DF.Currency