mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-24 20:46:38 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user