From 7d95acabe7ad37bf8d1a8a6034c22f00c6ad69fc Mon Sep 17 00:00:00 2001 From: Umair Sayed Date: Fri, 12 Jun 2026 14:45:02 +0530 Subject: [PATCH 1/6] fix(bom): allow zero qty for secondary items (Co-Product, By-Product, Scrap, Additional Finished Good) Secondary output items in a BOM do not always guarantee output during manufacture. The actual qty is only known when manufacturing completes, so setting zero in the BOM is a valid way to express "output is non-deterministic". Changes: - Remove `reqd: 1` from the qty field in BOM Secondary Item so that 0 is accepted as an explicit value (non_negative constraint is kept, so negative values are still rejected). - Relax validate_secondary_items() in bom.py to only reject qty that is None/missing, not qty that is explicitly 0. - Add a qty event handler in bom.js that shows a blue informational alert when the user sets qty to 0, explaining that the actual output will be recorded at manufacture time. Fixes https://github.com/frappe/erpnext/issues/55401 Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 6771daf6a1cd07b551ee60c098c996698545603a) # Conflicts: # erpnext/manufacturing/doctype/bom/bom.py --- erpnext/manufacturing/doctype/bom/bom.js | 13 +++++++++++++ erpnext/manufacturing/doctype/bom/bom.py | 7 ++++++- .../bom_secondary_item/bom_secondary_item.json | 3 +-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 9fbe4f1174c..3422ba1df7c 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -1012,6 +1012,19 @@ frappe.ui.form.on("BOM Secondary Item", { item_code(frm, cdt, cdn) { const { item_code } = locals[cdt][cdn]; }, + + qty(frm, cdt, cdn) { + const row = locals[cdt][cdn]; + if (flt(row.qty) === 0) { + frappe.show_alert({ + message: __( + "Row #{0}: Qty is set to zero for {1}. The actual output will be entered at the time of manufacture.", + [row.idx, frappe.bold(row.item_code)] + ), + indicator: "blue", + }); + } + }, }); function trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code) { diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 065707e7e6d..aabfe3c4b7b 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -341,10 +341,15 @@ class BOM(WebsiteGenerator): ).format(item.idx, get_link_to_form("Item", item.item_code)) ) - if not item.qty: + if item.qty is None: frappe.throw( +<<<<<<< HEAD _("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) +======= + _("Row #{0}: Quantity is required for {1} Item {2}").format( + item.idx, item.secondary_item_type, get_link_to_form("Item", item.item_code) +>>>>>>> 6771daf6a1 (fix(bom): allow zero qty for secondary items (Co-Product, By-Product, Scrap, Additional Finished Good)) ) ) 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..819b30ce602 100644 --- a/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.json +++ b/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.json @@ -138,8 +138,7 @@ "fieldtype": "Float", "in_list_view": 1, "label": "Qty", - "non_negative": 1, - "reqd": 1 + "non_negative": 1 }, { "default": "0", From 509038971c4d734dc69f937f2817d30194ed82c8 Mon Sep 17 00:00:00 2001 From: Umair Sayed Date: Fri, 12 Jun 2026 16:04:12 +0530 Subject: [PATCH 2/6] fix(manufacture): preserve user-entered rate for secondary items with zero cost allocation When a BOM secondary item has cost_allocation_per = 0 (the default), the previous code unconditionally computed `0 / transfer_qty = 0`, wiping any rate the user had entered for the item. Now the allocation formula only runs when cost_allocation_per > 0, allowing the valuation-rate fallback (or a manually entered rate) to apply instead. Additionally, secondary items with transfer_qty = 0 now short-circuit the entire rate pipeline: they get rate = 0 and amount = 0 immediately, avoiding a ZeroDivisionError and the spurious "enter basic rate" prompt. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit de3df6bcefa69f26990a6f5ab2e09af0182f686a) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index ae536b8dc42..ea62554e642 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1379,6 +1379,17 @@ class StockEntry(StockController, SubcontractingInwardController): for d in self.get("items"): if d.s_warehouse or d.set_basic_rate_manually: continue +<<<<<<< HEAD +======= + + # Zero-qty secondary items carry no inventory value; skip rate calculation + if d.secondary_item_type and flt(d.transfer_qty) == 0: + d.basic_rate = 0.0 + d.basic_amount = 0.0 + continue + + self._set_incoming_item_rate(d, outgoing_items_cost, raise_error_if_no_rate, zero_valuation_items) +>>>>>>> de3df6bcef (fix(manufacture): preserve user-entered rate for secondary items with zero cost allocation) if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer": d.basic_rate = 0.0 @@ -1399,9 +1410,22 @@ 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" + ) + # Only recalculate when cost is actually allocated; otherwise preserve the + # user-entered rate (or fall through to get_valuation_rate below) + if cost_allocation_per and flt(d.transfer_qty): + d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty +>>>>>>> de3df6bcef (fix(manufacture): preserve user-entered rate for secondary items with zero cost allocation) d.basic_rate = get_valuation_rate( d.item_code, From 532c343b57392f5f586d17ccfbd1570f88fe217e Mon Sep 17 00:00:00 2001 From: Umair Sayed Date: Fri, 12 Jun 2026 16:10:56 +0530 Subject: [PATCH 3/6] refactor(bom): remove qty=0 alert from BOM Secondary Item JS The informational toast is not required for the feature to work. The core fix (reqd removed from JSON, validation relaxed in bom.py) is sufficient to allow zero qty on BOM secondary items. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit bc7c0de208a36cff3d4edbc3ec6c48dfb6707940) --- erpnext/manufacturing/doctype/bom/bom.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 3422ba1df7c..9fbe4f1174c 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -1012,19 +1012,6 @@ frappe.ui.form.on("BOM Secondary Item", { item_code(frm, cdt, cdn) { const { item_code } = locals[cdt][cdn]; }, - - qty(frm, cdt, cdn) { - const row = locals[cdt][cdn]; - if (flt(row.qty) === 0) { - frappe.show_alert({ - message: __( - "Row #{0}: Qty is set to zero for {1}. The actual output will be entered at the time of manufacture.", - [row.idx, frappe.bold(row.item_code)] - ), - indicator: "blue", - }); - } - }, }); function trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code) { From 57a7bcae8e9ccde46e770ff9edf899e383bcba98 Mon Sep 17 00:00:00 2001 From: Mohammad Umair Sayed Date: Mon, 15 Jun 2026 15:06:24 +0530 Subject: [PATCH 4/6] refactor(bom): drop redundant secondary item qty None check Float fields default to 0, so qty is never None. Per review feedback, remove the validation entirely. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit c1bef53f92ded95d80abd255f94102f372d44b70) # Conflicts: # erpnext/manufacturing/doctype/bom/bom.py --- erpnext/manufacturing/doctype/bom/bom.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index aabfe3c4b7b..b92e920dd76 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -341,6 +341,7 @@ class BOM(WebsiteGenerator): ).format(item.idx, get_link_to_form("Item", item.item_code)) ) +<<<<<<< HEAD if item.qty is None: frappe.throw( <<<<<<< HEAD @@ -353,6 +354,8 @@ class BOM(WebsiteGenerator): ) ) +======= +>>>>>>> c1bef53f92 (refactor(bom): drop redundant secondary item qty None check) if item.process_loss_per >= 100: frappe.throw( _("Row #{0}: Process Loss Percentage should be less than 100% for {1} Item {2}").format( From c7cc0fec0f9e626456f81ec23309c9c9ac313f52 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 16 Jun 2026 15:12:06 +0530 Subject: [PATCH 5/6] fix: resolve v16 BOM backport conflicts --- erpnext/manufacturing/doctype/bom/bom.py | 15 ----------- .../stock/doctype/stock_entry/stock_entry.py | 25 ++++--------------- 2 files changed, 5 insertions(+), 35 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index b92e920dd76..6029a073961 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -341,21 +341,6 @@ class BOM(WebsiteGenerator): ).format(item.idx, get_link_to_form("Item", item.item_code)) ) -<<<<<<< HEAD - if item.qty is None: - frappe.throw( -<<<<<<< HEAD - _("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) -======= - _("Row #{0}: Quantity is required for {1} Item {2}").format( - item.idx, item.secondary_item_type, get_link_to_form("Item", item.item_code) ->>>>>>> 6771daf6a1 (fix(bom): allow zero qty for secondary items (Co-Product, By-Product, Scrap, Additional Finished Good)) - ) - ) - -======= ->>>>>>> c1bef53f92 (refactor(bom): drop redundant secondary item qty None check) if item.process_loss_per >= 100: frappe.throw( _("Row #{0}: Process Loss Percentage should be less than 100% for {1} Item {2}").format( diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index ea62554e642..55463364a3c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1379,18 +1379,13 @@ class StockEntry(StockController, SubcontractingInwardController): for d in self.get("items"): if d.s_warehouse or d.set_basic_rate_manually: continue -<<<<<<< HEAD -======= # Zero-qty secondary items carry no inventory value; skip rate calculation - if d.secondary_item_type and flt(d.transfer_qty) == 0: + if d.type and flt(d.transfer_qty) == 0: d.basic_rate = 0.0 d.basic_amount = 0.0 continue - self._set_incoming_item_rate(d, outgoing_items_cost, raise_error_if_no_rate, zero_valuation_items) ->>>>>>> de3df6bcef (fix(manufacture): preserve user-entered rate for secondary items with zero cost allocation) - if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer": d.basic_rate = 0.0 items.append(d.item_code) @@ -1408,24 +1403,14 @@ class StockEntry(StockController, SubcontractingInwardController): 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 + # Only recalculate when cost is actually allocated; otherwise preserve the + # user-entered rate (or fall through to get_valuation_rate below) + if cost_allocation_per and flt(d.transfer_qty): + 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" - ) - # Only recalculate when cost is actually allocated; otherwise preserve the - # user-entered rate (or fall through to get_valuation_rate below) - if cost_allocation_per and flt(d.transfer_qty): - d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty ->>>>>>> de3df6bcef (fix(manufacture): preserve user-entered rate for secondary items with zero cost allocation) d.basic_rate = get_valuation_rate( d.item_code, From 4f96073d59d01442dfbba9177fa59d81d8a32963 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 16 Jun 2026 16:49:48 +0530 Subject: [PATCH 6/6] fix: bump BOM secondary item metadata timestamp --- .../doctype/bom_secondary_item/bom_secondary_item.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 819b30ce602..ad2f69af5d9 100644 --- a/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.json +++ b/erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.json @@ -217,7 +217,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-03-11 12:12:29.208031", + "modified": "2026-06-16 16:49:19.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Secondary Item",