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
This commit is contained in:
rohitwaghchaure
2026-08-19 17:03:20 +05:30
committed by GitHub
parent 2524af4758
commit d07f4bb857
6 changed files with 173 additions and 3 deletions

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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")

View File

@@ -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

View File

@@ -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",

View File

@@ -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